feat(updater): Alpha version (#643)

Co-authored-by: Rajiv Shah <rajivshah1@icloud.com>
Co-authored-by: Lucas Nogueira <lucas@tauri.studio>
Co-authored-by: nothingismagick <denjell@mailscript.com>
Co-authored-by: Laegel <valentin.chouaf@laposte.net>
This commit is contained in:
david 2021-04-05 13:51:17 -04:00 committed by GitHub
parent 2ea56cf21d
commit 6d70c8e1e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
94 changed files with 7570 additions and 695 deletions

View File

@ -185,8 +185,7 @@
},
"tauri-updater": {
"path": "./tauri-updater",
"manager": "rust",
"publish": false
"manager": "rust"
},
"tauri": {
"path": "./tauri",

View File

@ -0,0 +1,8 @@
---
"tauri-updater": minor
"tauri-cli": minor
"tauri-bundler": minor
"tauri": minor
---
Alpha version of tauri-updater. Please refer to the `README` for more details.

62
.github/workflows/artifacts-updater.yml vendored Normal file
View File

@ -0,0 +1,62 @@
name: updater test artifacts
on:
pull_request:
paths:
- '.github/workflows/artifacts-updater.yml'
- 'tauri/**'
- 'tauri-updater/**'
- 'cli/core/**'
- 'cli/tauri-bundler/**'
- 'examples/updater/**'
jobs:
build-artifacs:
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v2
- name: install stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: install webkit2gtk (ubuntu only)
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y webkit2gtk-4.0
- run: cargo install --path ./cli/core --force
- name: install cli deps via yarn
working-directory: ./cli/tauri.js
run: yarn
- name: build cli
working-directory: ./cli/tauri.js
run: yarn build
- name: build sample artifacts (updater)
working-directory: ./examples/updater
run: |
yarn install
node ../../cli/tauri.js/bin/tauri build
env:
TAURI_PRIVATE_KEY: dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5YTBGV3JiTy9lRDZVd3NkL0RoQ1htZmExNDd3RmJaNmRMT1ZGVjczWTBKZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQWdMekUzVkE4K0tWQ1hjeGt1Vkx2QnRUR3pzQjVuV0ZpM2czWXNkRm9hVUxrVnB6TUN3K1NheHJMREhQbUVWVFZRK3NIL1VsMDBHNW5ET1EzQno0UStSb21nRW4vZlpTaXIwZFh5ZmRlL1lSN0dKcHdyOUVPclVvdzFhVkxDVnZrbHM2T1o4Tk1NWEU9Cg==
- uses: actions/upload-artifact@v2
if: matrix.platform == 'ubuntu-latest'
with:
name: linux-updater-artifacts
path: ./target/release/bundle/appimage/updater-example_*.AppImage.*
- uses: actions/upload-artifact@v2
if: matrix.platform == 'windows-latest'
with:
name: windows-updater-artifacts
path: ./target/release/bundle/msi/*
- uses: actions/upload-artifact@v2
if: matrix.platform == 'macos-latest'
with:
name: macos-updater-artifacts
path: ./target/release/bundle/macos/updater-example_*.app.tar.*

View File

@ -23,6 +23,11 @@ jobs:
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: install webkit2gtk (ubuntu only)
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y webkit2gtk-4.0
- name: test
run: |
cd ./cli/tauri-bundler
@ -34,6 +39,11 @@ jobs:
steps:
- uses: actions/checkout@v2
- run: rustup component add clippy
- name: install webkit2gtk (ubuntu only)
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y webkit2gtk-4.0
- name: clippy check
uses: actions-rs/clippy-check@v1
with:

View File

@ -13,6 +13,8 @@ members = [
"examples/api/src-tauri",
"examples/helloworld/src-tauri",
"examples/multiwindow/src-tauri",
# used to build updater artifacts
"examples/updater/src-tauri",
]
# default to small, optimized workspace release binaries

View File

@ -8,6 +8,7 @@
"./cli": "./dist/cli.js",
"./dialog": "./dist/dialog.js",
"./event": "./dist/event.js",
"./updater": "./dist/updater.js",
"./fs": "./dist/fs.js",
"./path": "./dist/path.js",
"./http": "./dist/http.js",

View File

@ -14,6 +14,7 @@ export default [
path: './src/path.ts',
dialog: './src/dialog.ts',
event: './src/event.ts',
updater: './src/updater.ts',
http: './src/http.ts',
index: './src/index.ts',
shell: './src/shell.ts',

View File

@ -2,6 +2,7 @@ import 'regenerator-runtime/runtime'
import * as cli from './cli'
import * as dialog from './dialog'
import * as event from './event'
import * as updater from './updater'
import * as fs from './fs'
import * as path from './path'
import * as http from './http'
@ -15,6 +16,7 @@ export {
cli,
dialog,
event,
updater,
fs,
path,
http,

130
api/src/updater.ts Normal file
View File

@ -0,0 +1,130 @@
import { once, listen, emit, UnlistenFn } from './helpers/event'
export type UpdateStatus = 'PENDING' | 'ERROR' | 'DONE' | 'UPTODATE'
export interface UpdateStatusResult {
error?: string
status: UpdateStatus
}
export interface UpdateManifest {
version: string
date: string
body: string
}
export interface UpdateResult {
manifest?: UpdateManifest
shouldUpdate: boolean
}
export async function installUpdate(): Promise<void> {
let unlistenerFn: UnlistenFn | undefined
function cleanListener(): void {
if (unlistenerFn) {
unlistenerFn()
}
unlistenerFn = undefined
}
return new Promise((resolve, reject) => {
function onStatusChange(statusResult: UpdateStatusResult): void {
if (statusResult.error) {
cleanListener()
return reject(statusResult.error)
}
// install complete
if (statusResult.status === 'DONE') {
cleanListener()
return resolve()
}
}
// listen status change
listen('tauri://update-status', (data: { payload: any }) => {
onStatusChange(data?.payload as UpdateStatusResult)
})
.then((fn) => {
unlistenerFn = fn
})
.catch((e) => {
cleanListener()
// dispatch the error to our checkUpdate
throw e
})
// start the process we dont require much security as it's
// handled by rust
emit('tauri://update-install').catch((e) => {
cleanListener()
// dispatch the error to our checkUpdate
throw e
})
})
}
export async function checkUpdate(): Promise<UpdateResult> {
let unlistenerFn: UnlistenFn | undefined
function cleanListener(): void {
if (unlistenerFn) {
unlistenerFn()
}
unlistenerFn = undefined
}
return new Promise((resolve, reject) => {
function onUpdateAvailable(manifest: UpdateManifest): void {
cleanListener()
return resolve({
manifest,
shouldUpdate: true
})
}
function onStatusChange(statusResult: UpdateStatusResult): void {
if (statusResult.error) {
cleanListener()
return reject(statusResult.error)
}
if (statusResult.status === 'UPTODATE') {
cleanListener()
return resolve({
shouldUpdate: false
})
}
}
// wait to receive the latest update
once('tauri://update-available', (data: { payload: any }) => {
onUpdateAvailable(data?.payload as UpdateManifest)
}).catch((e) => {
cleanListener()
// dispatch the error to our checkUpdate
throw e
})
// listen status change
listen('tauri://update-status', (data: { payload: any }) => {
onStatusChange(data?.payload as UpdateStatusResult)
})
.then((fn) => {
unlistenerFn = fn
})
.catch((e) => {
cleanListener()
// dispatch the error to our checkUpdate
throw e
})
// start the process
emit('tauri://update').catch((e) => {
cleanListener()
// dispatch the error to our checkUpdate
throw e
})
})
}

88
cli/core/Cargo.lock generated
View File

@ -236,6 +236,15 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e"
[[package]]
name = "cipher"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801"
dependencies = [
"generic-array 0.14.4",
]
[[package]]
name = "clap"
version = "3.0.0-beta.2"
@ -390,6 +399,16 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "crypto-mac"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6"
dependencies = [
"generic-array 0.14.4",
"subtle",
]
[[package]]
name = "darling"
version = "0.12.2"
@ -729,6 +748,16 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15"
dependencies = [
"crypto-mac",
"digest 0.9.0",
]
[[package]]
name = "http"
version = "0.2.3"
@ -997,6 +1026,17 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minisign"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59eb3626cabfaad94cca37c4b4f424705fd2567c16b4e000d61039405b384dda"
dependencies = [
"getrandom 0.2.2",
"rpassword",
"scrypt",
]
[[package]]
name = "miniz_oxide"
version = "0.3.7"
@ -1231,6 +1271,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afb2e1c3ee07430c2cf76151675e583e0f19985fa6efae47d6848a3e2c824f85"
[[package]]
name = "pbkdf2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "309c95c5f738c85920eb7062a2de29f3840d4f96974453fc9ac1ba078da9c627"
dependencies = [
"crypto-mac",
]
[[package]]
name = "percent-encoding"
version = "2.1.0"
@ -1588,6 +1637,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cabe4fa914dec5870285fa7f71f602645da47c486e68486d2b4ceb4a343e90ac"
[[package]]
name = "rpassword"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb"
dependencies = [
"libc",
"winapi 0.3.9",
]
[[package]]
name = "rustc-demangle"
version = "0.1.18"
@ -1619,6 +1678,15 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e"
[[package]]
name = "salsa20"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "399f290ffc409596022fce5ea5d4138184be4784f2b28c62c59f0d8389059a15"
dependencies = [
"cipher",
]
[[package]]
name = "same-file"
version = "1.0.6"
@ -1674,6 +1742,18 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "scrypt"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25cba843abb27147110e2e9aa82b3be81eb386a9c6e3b35504c8c2ee305fd643"
dependencies = [
"hmac",
"pbkdf2",
"salsa20",
"sha2",
]
[[package]]
name = "sct"
version = "0.6.0"
@ -1855,6 +1935,12 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "subtle"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2"
[[package]]
name = "syn"
version = "1.0.65"
@ -1941,6 +2027,7 @@ name = "tauri-cli"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"clap",
"colored",
"dialoguer",
@ -1948,6 +2035,7 @@ dependencies = [
"heck",
"include_dir",
"json-patch",
"minisign",
"notify",
"once_cell",
"os_info",

View File

@ -29,6 +29,8 @@ valico = "3.6"
handlebars = "3.5"
include_dir = "0.6"
dialoguer = "0.8"
minisign = "0.6"
base64 = "0.13.0"
ureq = "2.1"
os_info = "3.0"
semver = "0.11"

View File

@ -502,7 +502,6 @@ impl Allowlist for AllowlistConfig {
#[skip_serializing_none]
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TauriConfig {
/// The windows configuration.
#[serde(default)]
@ -515,6 +514,9 @@ pub struct TauriConfig {
#[serde(default)]
allowlist: AllowlistConfig,
pub security: Option<SecurityConfig>,
/// The updater configuration.
#[serde(default = "default_updater")]
pub updater: UpdaterConfig,
}
impl TauriConfig {
@ -524,6 +526,29 @@ impl TauriConfig {
}
}
#[skip_serializing_none]
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdaterConfig {
/// Whether the updater is active or not.
pub active: bool,
/// Display built-in dialog or use event system if disabled.
#[serde(default = "default_dialog")]
pub dialog: Option<bool>,
/// The updater endpoints.
pub endpoints: Option<Vec<String>>,
/// Optional pubkey.
pub pubkey: Option<String>,
}
// We enable the unnecessary_wraps because we need
// to use an Option for dialog otherwise the CLI schema will mark
// the dialog as a required field which is not as we default it to true.
#[allow(clippy::unnecessary_wraps)]
fn default_dialog() -> Option<bool> {
Some(true)
}
/// The Build configuration object.
#[skip_serializing_none]
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize, JsonSchema)]
@ -582,3 +607,12 @@ fn default_build() -> BuildConfig {
with_global_tauri: false,
}
}
fn default_updater() -> UpdaterConfig {
UpdaterConfig {
active: false,
dialog: Some(true),
endpoints: None,
pubkey: None,
}
}

View File

@ -93,6 +93,9 @@
"timestampUrl": null
}
},
"updater": {
"active": false
},
"windows": []
},
"allOf": [
@ -938,6 +941,18 @@
}
]
},
"updater": {
"description": "The updater configuration.",
"default": {
"active": false,
"dialog": true
},
"allOf": [
{
"$ref": "#/definitions/UpdaterConfig"
}
]
},
"windows": {
"description": "The windows configuration.",
"default": [],
@ -949,6 +964,44 @@
},
"additionalProperties": false
},
"UpdaterConfig": {
"type": "object",
"required": [
"active"
],
"properties": {
"active": {
"description": "Whether the updater is active or not.",
"type": "boolean"
},
"dialog": {
"description": "Display built-in dialog or use event system if disabled.",
"default": true,
"type": [
"boolean",
"null"
]
},
"endpoints": {
"description": "The updater endpoints.",
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"pubkey": {
"description": "Optional pubkey.",
"type": [
"string",
"null"
]
}
},
"additionalProperties": false
},
"WindowAllowlistConfig": {
"type": "object",
"properties": {

View File

@ -1,10 +1,13 @@
use tauri_bundler::bundle::{bundle_project, PackageType, SettingsBuilder};
use tauri_bundler::bundle::{
bundle_project, common::print_signed_updater_archive, PackageType, SettingsBuilder,
};
use crate::helpers::{
app_paths::{app_dir, tauri_dir},
config::get as get_config,
execute_with_output,
manifest::rewrite_manifest,
updater_signature::sign_file_from_env_variables,
Logger,
};
@ -129,6 +132,7 @@ impl Build {
if self.verbose {
settings_builder = settings_builder.verbose();
}
if let Some(names) = self.targets {
let mut types = vec![];
for name in names {
@ -147,10 +151,35 @@ impl Build {
}
}
}
settings_builder = settings_builder.package_types(types);
}
// Bundle the project
let settings = settings_builder.build()?;
bundle_project(settings)?;
let bundles = bundle_project(settings)?;
// If updater is active and pubkey is available
if config_.tauri.updater.active && config_.tauri.updater.pubkey.is_some() {
// make sure we have our package builts
let mut signed_paths = Vec::new();
for elem in bundles
.iter()
.filter(|bundle| bundle.package_type == PackageType::Updater)
{
// we expect to have only one path in the vec but we iter if we add
// another type of updater package who require multiple file signature
for path in elem.bundle_paths.iter() {
// sign our path from environment variables
let (signature_path, _signature) = sign_file_from_env_variables(path)?;
signed_paths.append(&mut vec![signature_path]);
}
}
if !signed_paths.is_empty() {
print_signed_updater_archive(&signed_paths)?;
}
}
}
Ok(())

View File

@ -7,6 +7,7 @@ use crate::helpers::{app_paths::tauri_dir, config::Config};
use tauri_bundler::WindowsSettings;
use tauri_bundler::{
AppCategory, BundleBinary, BundleSettings, DebianSettings, MacOSSettings, PackageSettings,
UpdaterSettings,
};
/// The `workspace` section of the app configuration (read from Cargo.toml).
@ -141,7 +142,7 @@ impl AppSettings {
}
pub fn get_bundle_settings(&self, config: &Config) -> crate::Result<BundleSettings> {
tauri_config_to_bundle_settings(config.tauri.bundle.clone())
tauri_config_to_bundle_settings(config.tauri.bundle.clone(), config.tauri.updater.clone())
}
pub fn get_out_dir(&self, debug: bool) -> crate::Result<PathBuf> {
@ -306,6 +307,7 @@ pub fn get_workspace_dir(current_dir: &PathBuf) -> PathBuf {
fn tauri_config_to_bundle_settings(
config: crate::helpers::config::BundleConfig,
updater_config: crate::helpers::config::UpdaterConfig,
) -> crate::Result<BundleSettings> {
Ok(BundleSettings {
identifier: config.identifier,
@ -341,6 +343,14 @@ fn tauri_config_to_bundle_settings(
digest_algorithm: config.windows.digest_algorithm,
certificate_thumbprint: config.windows.certificate_thumbprint,
},
updater: Some(UpdaterSettings {
active: updater_config.active,
// we set it to true by default we shouldn't have to use
// unwrap_or as we have a default value but used to prevent any failing
dialog: updater_config.dialog.unwrap_or(true),
pubkey: updater_config.pubkey,
endpoints: updater_config.endpoints,
}),
..Default::default()
})
}

View File

@ -45,6 +45,51 @@ subcommands:
long: config
about: config JSON to merge with tauri.conf.json
takes_value: true
- sign:
about: Tauri updates signer.
args:
- generate:
short: g
long: generate
about: Generate keypair to sign files
- sign-file:
long: sign-file
about: Sign the specified file
takes_value: true
- private-key-path:
short: f
long: private-key-path
about: Load the private key from a file
takes_value: true
conflicts_with: private-key
- private-key:
short: k
long: private-key
about: Load the private key from a string
takes_value: true
conflicts_with: private-key-path
requires: sign-file
- write-keys:
short: w
long: write-keys
about: Write private key to a file
takes_value: true
requires: generate
- password:
short: p
long: password
about: Set private key password when signing
takes_value: true
conflicts_with: no-password
- no-password:
long: no-password
about: Set empty password for your private key
conflicts_with: password
- force:
long: force
about: Overwrite private key even if it exists on the specified path
requires: generate
- info:
about: Shows information about Tauri dependencies
- init:

View File

@ -2,6 +2,7 @@ pub mod app_paths;
pub mod config;
mod logger;
pub mod manifest;
pub mod updater_signature;
pub use logger::Logger;

View File

@ -0,0 +1,186 @@
extern crate minisign;
use base64::{decode, encode};
use minisign::{sign, KeyPair as KP, SecretKeyBox};
use std::{
env::var_os,
fs::{self, File, OpenOptions},
io::{BufReader, Write},
path::{Path, PathBuf},
str,
time::{SystemTime, UNIX_EPOCH},
};
use tauri_bundler::bundle::common::create_file;
/// A key pair (`PublicKey` and `SecretKey`).
#[derive(Clone, Debug)]
pub struct KeyPair {
pub pk: String,
pub sk: String,
}
/// Generate base64 encoded keypair
pub fn generate_key(password: Option<String>) -> crate::Result<KeyPair> {
let KP { pk, sk } = KP::generate_encrypted_keypair(password).unwrap();
let pk_box_str = pk.to_box().unwrap().to_string();
let sk_box_str = sk.to_box(None).unwrap().to_string();
let encoded_pk = encode(&pk_box_str);
let encoded_sk = encode(&sk_box_str);
Ok(KeyPair {
pk: encoded_pk,
sk: encoded_sk,
})
}
/// Transform a base64 String to readable string for the main signer
pub fn decode_key(base64_key: String) -> crate::Result<String> {
let decoded_str = &decode(&base64_key)?[..];
Ok(String::from(str::from_utf8(decoded_str)?))
}
/// Save KeyPair to disk
pub fn save_keypair<P>(
force: bool,
sk_path: P,
key: &str,
pubkey: &str,
) -> crate::Result<(PathBuf, PathBuf)>
where
P: AsRef<Path>,
{
let sk_path = sk_path.as_ref();
let pubkey_path = format!("{}.pub", sk_path.display());
let pk_path = Path::new(&pubkey_path);
if sk_path.exists() {
if !force {
return Err(anyhow::anyhow!(
"Key generation aborted:\n{} already exists\nIf you really want to overwrite the existing key pair, add the --force switch to force this operation.",
sk_path.display()
));
} else {
std::fs::remove_file(&sk_path)?;
}
}
if pk_path.exists() {
std::fs::remove_file(&pk_path)?;
}
let mut sk_writer = create_file(&sk_path)?;
write!(sk_writer, "{:}", key)?;
sk_writer.flush()?;
let mut pk_writer = create_file(&pk_path)?;
write!(pk_writer, "{:}", pubkey)?;
pk_writer.flush()?;
Ok((fs::canonicalize(&sk_path)?, fs::canonicalize(&pk_path)?))
}
/// Read key from file
pub fn read_key_from_file<P>(sk_path: P) -> crate::Result<String>
where
P: AsRef<Path>,
{
Ok(fs::read_to_string(sk_path)?)
}
/// Sign files
pub fn sign_file<P>(
private_key: String,
password: String,
bin_path: P,
prehashed: bool,
) -> crate::Result<(PathBuf, String)>
where
P: AsRef<Path>,
{
let decoded_secret = decode_key(private_key)?;
let sk_box = SecretKeyBox::from_string(&decoded_secret).unwrap();
let sk = sk_box.into_secret_key(Some(password)).unwrap();
// We need to append .sig at the end it's where the signature will be stored
let signature_path_string = format!("{}.sig", bin_path.as_ref().display());
let signature_path = Path::new(&signature_path_string);
let mut signature_box_writer = create_file(&signature_path)?;
let trusted_comment = format!(
"timestamp:{}\tfile:{}",
unix_timestamp(),
bin_path.as_ref().display()
);
let (data_reader, should_be_prehashed) = open_data_file(bin_path)?;
let signature_box = sign(
None,
&sk,
data_reader,
prehashed | should_be_prehashed,
Some(trusted_comment.as_str()),
Some("signature from tauri secret key"),
)?;
let encoded_signature = encode(&signature_box.to_string());
signature_box_writer.write_all(&encoded_signature.as_bytes())?;
signature_box_writer.flush()?;
Ok((fs::canonicalize(&signature_path)?, encoded_signature))
}
/// Sign files using the TAURI_KEY_PASSWORD and TAURI_PRIVATE_KEY environment variables
pub fn sign_file_from_env_variables<P>(path_to_sign: P) -> crate::Result<(PathBuf, String)>
where
P: AsRef<Path>,
{
// if no password provided we set empty string
let password_string = match var_os("TAURI_KEY_PASSWORD") {
Some(value) => String::from(value.to_str().unwrap()),
None => "".into(),
};
// get the private key
if let Some(private_key) = var_os("TAURI_PRIVATE_KEY") {
// check if this file exist..
let mut private_key_string = String::from(private_key.to_str().unwrap());
let pk_dir = Path::new(&private_key_string);
// Check if user provided a path or a key
// We validate if the path exist or no.
if pk_dir.exists() {
// read file content as use it as private key
private_key_string = read_key_from_file(pk_dir)?;
}
// sign our file
return sign_file(private_key_string, password_string, path_to_sign, false);
}
// reject if we don't have the private key
Err(anyhow::anyhow!("A public key has been found, but no private key. Make sure to set `TAURI_PRIVATE_KEY` environment variable."))
}
fn unix_timestamp() -> u64 {
let start = SystemTime::now();
let since_the_epoch = start
.duration_since(UNIX_EPOCH)
.expect("system clock is incorrect");
since_the_epoch.as_secs()
}
fn open_data_file<P>(data_path: P) -> crate::Result<(BufReader<File>, bool)>
where
P: AsRef<Path>,
{
let data_path = data_path.as_ref();
let file = OpenOptions::new()
.read(true)
.open(data_path)
.map_err(|e| minisign::PError::new(minisign::ErrorKind::Io, e))?;
let should_be_hashed = match file.metadata() {
Ok(metadata) => metadata.len() > (1u64 << 30),
Err(_) => true,
};
Ok((BufReader::new(file), should_be_hashed))
}

View File

@ -7,6 +7,7 @@ mod dev;
mod helpers;
mod info;
mod init;
mod sign;
pub use helpers::Logger;
@ -121,6 +122,63 @@ fn info_command() -> Result<()> {
info::Info::new().run()
}
fn sign_command(matches: &ArgMatches) -> Result<()> {
let private_key = matches.value_of("private-key");
let private_key_path = matches.value_of("private-key-path");
let file = matches.value_of("sign-file");
let password = matches.value_of("password");
let no_password = matches.is_present("no-password");
let write_keys = matches.value_of("write-keys");
let force = matches.is_present("force");
// generate keypair
if matches.is_present("generate") {
let mut keygen_runner = sign::KeyGenerator::new();
if no_password {
keygen_runner = keygen_runner.empty_password();
}
if force {
keygen_runner = keygen_runner.force();
}
if let Some(write_keys) = write_keys {
keygen_runner = keygen_runner.output_path(write_keys);
}
if let Some(password) = password {
keygen_runner = keygen_runner.password(password);
}
return keygen_runner.generate_keys();
}
// sign our binary / archive
let mut sign_runner = sign::Signer::new();
if let Some(private_key) = private_key {
sign_runner = sign_runner.private_key(private_key);
}
if let Some(private_key_path) = private_key_path {
sign_runner = sign_runner.private_key_path(private_key_path);
}
if let Some(file) = file {
sign_runner = sign_runner.file_to_sign(file);
}
if let Some(password) = password {
sign_runner = sign_runner.password(password);
}
if no_password {
sign_runner = sign_runner.empty_password();
}
sign_runner.run()
}
fn main() -> Result<()> {
let yaml = load_yaml!("cli.yml");
let app = App::from(yaml)
@ -139,6 +197,8 @@ fn main() -> Result<()> {
build_command(&matches)?;
} else if matches.subcommand_matches("info").is_some() {
info_command()?;
} else if let Some(matches) = matches.subcommand_matches("sign") {
sign_command(&matches)?;
}
Ok(())

134
cli/core/src/sign.rs Normal file
View File

@ -0,0 +1,134 @@
use crate::helpers::updater_signature::{
generate_key, read_key_from_file, save_keypair, sign_file,
};
use std::path::{Path, PathBuf};
#[derive(Default)]
pub struct Signer {
private_key: Option<String>,
password: Option<String>,
file: Option<PathBuf>,
}
impl Signer {
pub fn new() -> Self {
Default::default()
}
pub fn private_key(mut self, private_key: &str) -> Self {
self.private_key = Some(private_key.to_owned());
self
}
pub fn password(mut self, password: &str) -> Self {
self.password = Some(password.to_owned());
self
}
pub fn empty_password(mut self) -> Self {
self.password = Some("".to_owned());
self
}
pub fn file_to_sign(mut self, file_path: &str) -> Self {
self.file = Some(Path::new(file_path).to_path_buf());
self
}
pub fn private_key_path(mut self, private_key: &str) -> Self {
self.private_key =
Some(read_key_from_file(Path::new(private_key)).expect("Unable to extract private key"));
self
}
pub fn run(self) -> crate::Result<()> {
if self.private_key.is_none() {
return Err(anyhow::anyhow!(
"Key generation aborted: Unable to find the private key".to_string(),
));
}
if self.password.is_none() {
return Err(anyhow::anyhow!(
"Please use --no-password to set empty password or add --password <password> if your private key have a password.".to_string(),
));
}
let (manifest_dir, signature) = sign_file(
self.private_key.unwrap(),
self.password.unwrap(),
self.file.unwrap(),
false,
)?;
println!(
"\nYour file was signed successfully, You can find the signature here:\n{}\n\nPublic signature:\n{}\n\nMake sure to include this into the signature field of your update server.",
manifest_dir.display(),
signature
);
Ok(())
}
}
#[derive(Default)]
pub struct KeyGenerator {
password: Option<String>,
output_path: Option<PathBuf>,
force: bool,
}
impl KeyGenerator {
pub fn new() -> Self {
Default::default()
}
pub fn empty_password(mut self) -> Self {
self.password = Some("".to_owned());
self
}
pub fn force(mut self) -> Self {
self.force = true;
self
}
pub fn password(mut self, password: &str) -> Self {
self.password = Some(password.to_owned());
self
}
pub fn output_path(mut self, output_path: &str) -> Self {
self.output_path = Some(Path::new(output_path).to_path_buf());
self
}
pub fn generate_keys(self) -> crate::Result<()> {
let keypair = generate_key(self.password).expect("Failed to generate key");
if let Some(output_path) = self.output_path {
let (secret_path, public_path) =
save_keypair(self.force, output_path, &keypair.sk, &keypair.pk)
.expect("Unable to write keypair");
println!(
"\nYour keypair was generated successfully\nPrivate: {} (Keep it secret!)\nPublic: {}\n---------------------------",
secret_path.display(),
public_path.display()
)
} else {
println!(
"\nYour secret key was generated successfully - Keep it secret!\n{}\n\n",
keypair.sk
);
println!(
"Your public key was generated successfully:\n{}\n\nAdd the public key in your tauri.conf.json\n---------------------------\n",
keypair.pk
);
}
println!("\nEnvironment variabled used to sign:\n`TAURI_PRIVATE_KEY` Path or String of your private key\n`TAURI_KEY_PASSWORD` Your private key password (optional)\n\nATTENTION: If you lose your private key OR password, you'll not be able to sign your update package and updates will not works.\n---------------------------\n");
Ok(())
}
}

View File

@ -6,5 +6,6 @@
fn main() {
tauri::AppBuilder::default()
.build(tauri::generate_context!())
.run();
.run()
.expect("error while running tauri application");
}

View File

@ -45,6 +45,9 @@
"timestampUrl": ""
}
},
"updater": {
"active": false
},
"allowlist": {
"all": true
},

View File

@ -1,6 +1,6 @@
mod appimage_bundle;
mod category;
mod common;
pub mod common;
mod deb_bundle;
mod dmg_bundle;
mod ios_bundle;
@ -11,6 +11,7 @@ mod path_utils;
mod platform;
mod rpm_bundle;
mod settings;
mod updater_bundle;
#[cfg(target_os = "windows")]
mod wix;
@ -19,7 +20,7 @@ pub use self::{
common::{print_error, print_info},
settings::{
BundleBinary, BundleSettings, DebianSettings, MacOSSettings, PackageSettings, PackageType,
Settings, SettingsBuilder,
Settings, SettingsBuilder, UpdaterSettings,
},
};
#[cfg(windows)]
@ -29,37 +30,46 @@ use common::print_finished;
use std::path::PathBuf;
pub struct Bundle {
// the package type
pub package_type: PackageType,
/// all paths for this package
pub bundle_paths: Vec<PathBuf>,
}
/// Bundles the project.
/// Returns the list of paths where the bundles can be found.
pub fn bundle_project(settings: Settings) -> crate::Result<Vec<PathBuf>> {
let mut paths = Vec::new();
pub fn bundle_project(settings: Settings) -> crate::Result<Vec<Bundle>> {
let mut bundles = Vec::new();
let package_types = settings.package_types()?;
for package_type in &package_types {
let mut bundle_paths = match package_type {
PackageType::MacOSBundle => {
if package_types.clone().iter().any(|&t| t == PackageType::Dmg) {
vec![]
} else {
macos_bundle::bundle_project(&settings)?
}
}
let bundle_paths = match package_type {
PackageType::MacOSBundle => macos_bundle::bundle_project(&settings)?,
PackageType::IosBundle => ios_bundle::bundle_project(&settings)?,
#[cfg(target_os = "windows")]
PackageType::WindowsMsi => msi_bundle::bundle_project(&settings)?,
PackageType::Deb => deb_bundle::bundle_project(&settings)?,
PackageType::Rpm => rpm_bundle::bundle_project(&settings)?,
PackageType::AppImage => appimage_bundle::bundle_project(&settings)?,
PackageType::Dmg => dmg_bundle::bundle_project(&settings)?,
// dmg is dependant of MacOSBundle, we send our bundles to prevent rebuilding
PackageType::Dmg => dmg_bundle::bundle_project(&settings, &bundles)?,
// updater is dependant of multiple bundle, we send our bundles to prevent rebuilding
PackageType::Updater => updater_bundle::bundle_project(&settings, &bundles)?,
};
paths.append(&mut bundle_paths);
bundles.push(Bundle {
package_type: package_type.to_owned(),
bundle_paths,
});
}
settings.copy_resources(settings.project_out_directory())?;
settings.copy_binaries(settings.project_out_directory())?;
print_finished(&paths)?;
print_finished(&bundles)?;
Ok(paths)
Ok(bundles)
}
/// Check to see if there are icons in the settings struct

View File

@ -150,14 +150,36 @@ pub fn print_bundling(filename: &str) -> crate::Result<()> {
/// Prints a message to stderr, in the same format that `cargo` uses,
/// indicating that we have finished the the given bundles.
pub fn print_finished(output_paths: &[PathBuf]) -> crate::Result<()> {
let pluralised = if output_paths.len() == 1 {
pub fn print_finished(bundles: &[crate::bundle::Bundle]) -> crate::Result<()> {
let pluralised = if bundles.len() == 1 {
"bundle"
} else {
"bundles"
};
let msg = format!("{} {} at:", output_paths.len(), pluralised);
let msg = format!("{} {} at:", bundles.len(), pluralised);
print_progress("Finished", &msg)?;
for bundle in bundles {
for path in &bundle.bundle_paths {
let mut note = "";
if bundle.package_type == crate::PackageType::Updater {
note = " (updater)";
}
println!(" {}{}", path.display(), note,);
}
}
Ok(())
}
/// Prints a message to stderr, in the same format that `cargo` uses,
/// indicating that we have finished the the given signatures.
pub fn print_signed_updater_archive(output_paths: &[PathBuf]) -> crate::Result<()> {
let pluralised = if output_paths.len() == 1 {
"updater archive"
} else {
"updater archives"
};
let msg = format!("{} {} at:", output_paths.len(), pluralised);
print_progress("Signed", &msg)?;
for path in output_paths {
println!(" {}", path.display());
}

View File

@ -1,5 +1,5 @@
use super::{common, macos_bundle};
use crate::Settings;
use crate::{bundle::Bundle, PackageType::MacOSBundle, Settings};
use anyhow::Context;
@ -12,9 +12,16 @@ use std::{
/// Bundles the project.
/// Returns a vector of PathBuf that shows where the DMG was created.
pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
// generate the .app bundle
macos_bundle::bundle_project(settings)?;
pub fn bundle_project(settings: &Settings, bundles: &[Bundle]) -> crate::Result<Vec<PathBuf>> {
// generate the .app bundle if needed
if bundles
.iter()
.filter(|bundle| bundle.package_type == MacOSBundle)
.count()
== 0
{
macos_bundle::bundle_project(settings)?;
}
// get the target path
let output_path = settings.project_out_directory().join("bundle/dmg");
@ -32,7 +39,6 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
let product_name = &format!("{}.app", &package_base_name);
let bundle_dir = settings.project_out_directory().join("bundle/macos");
let bundle_path = bundle_dir.join(&product_name.clone());
let support_directory_path = output_path.join("support");
if output_path.exists() {
@ -80,8 +86,11 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
let mut args = vec![
"--volname",
&package_base_name,
"--volicon",
"../../../../icons/icon.icns",
// todo: volume icon
// make sure this is a valid path?
//"--volicon",
//"../../../../icons/icon.icns",
"--icon",
&product_name,
"180",
@ -134,5 +143,5 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
if let Some(identity) = &settings.macos().signing_identity {
crate::bundle::macos_bundle::sign(dmg_path.clone(), identity, &settings, false)?;
}
Ok(vec![bundle_path, dmg_path])
Ok(vec![dmg_path])
}

View File

@ -26,11 +26,13 @@ pub enum PackageType {
AppImage,
/// The macOS DMG bundle (.dmg).
Dmg,
/// The Updater bundle.
Updater,
}
impl PackageType {
/// Maps a short name to a PackageType.
/// Possible values are "deb", "ios", "msi", "app", "rpm", "appimage", "dmg".
/// Possible values are "deb", "ios", "msi", "app", "rpm", "appimage", "dmg", "updater".
pub fn from_short_name(name: &str) -> Option<PackageType> {
// Other types we may eventually want to support: apk.
match name {
@ -42,6 +44,7 @@ impl PackageType {
"rpm" => Some(PackageType::Rpm),
"appimage" => Some(PackageType::AppImage),
"dmg" => Some(PackageType::Dmg),
"updater" => Some(PackageType::Updater),
_ => None,
}
}
@ -58,6 +61,7 @@ impl PackageType {
PackageType::Rpm => "rpm",
PackageType::AppImage => "appimage",
PackageType::Dmg => "dmg",
PackageType::Updater => "updater",
}
}
@ -76,6 +80,7 @@ const ALL_PACKAGE_TYPES: &[PackageType] = &[
PackageType::Rpm,
PackageType::Dmg,
PackageType::AppImage,
PackageType::Updater,
];
/// The package settings.
@ -95,6 +100,19 @@ pub struct PackageSettings {
pub default_run: Option<String>,
}
/// The updater settings.
#[derive(Debug, Clone, Deserialize)]
pub struct UpdaterSettings {
/// Whether the updater is active or not.
pub active: bool,
/// The updater endpoints.
pub endpoints: Option<Vec<String>>,
/// Optional pubkey.
pub pubkey: Option<String>,
/// Display built-in dialog or use event system if disabled.
pub dialog: bool,
}
/// The Linux debian bundle settings.
#[derive(Clone, Debug, Deserialize, Default)]
pub struct DebianSettings {
@ -189,6 +207,8 @@ pub struct BundleSettings {
pub deb: DebianSettings,
/// MacOS-specific settings.
pub macos: MacOSSettings,
// Updater configuration
pub updater: Option<UpdaterSettings>,
/// Windows-specific settings.
#[cfg(windows)]
pub windows: WindowsSettings,
@ -372,7 +392,7 @@ impl Settings {
/// Fails if the host/target's native package type is not supported.
pub fn package_types(&self) -> crate::Result<Vec<PackageType>> {
let target_os = std::env::consts::OS;
let platform_types = match target_os {
let mut platform_types = match target_os {
"macos" => vec![PackageType::MacOSBundle, PackageType::Dmg],
"ios" => vec![PackageType::IosBundle],
"linux" => vec![PackageType::Deb, PackageType::AppImage],
@ -385,6 +405,12 @@ impl Settings {
)))
}
};
// add updater if needed
if self.is_update_enabled() {
platform_types.push(PackageType::Updater)
}
if let Some(package_types) = &self.package_types {
let mut types = vec![];
for package_type in package_types {
@ -535,6 +561,34 @@ impl Settings {
pub fn windows(&self) -> &WindowsSettings {
&self.bundle_settings.windows
}
/// Is update enabled
pub fn is_update_enabled(&self) -> bool {
match &self.bundle_settings.updater {
Some(val) => val.active,
None => false,
}
}
/// Is pubkey provided?
pub fn is_updater_pubkey(&self) -> bool {
match &self.bundle_settings.updater {
Some(val) => val.pubkey.is_some(),
None => false,
}
}
/// Get pubkey (mainly for testing)
#[cfg(test)]
pub fn updater_pubkey(&self) -> Option<&str> {
self
.bundle_settings
.updater
.as_ref()
.expect("Updater is not defined")
.pubkey
.as_deref()
}
}
/// Parses the external binaries to bundle, adding the target triple suffix to each of them.

View File

@ -0,0 +1,234 @@
use super::common;
use libflate::gzip;
use walkdir::WalkDir;
#[cfg(target_os = "macos")]
use super::macos_bundle;
#[cfg(target_os = "linux")]
use super::appimage_bundle;
#[cfg(target_os = "windows")]
use super::msi_bundle;
#[cfg(target_os = "windows")]
use std::fs::File;
#[cfg(target_os = "windows")]
use std::io::prelude::*;
#[cfg(target_os = "windows")]
use zip::write::FileOptions;
use crate::{bundle::Bundle, Settings};
use std::{
ffi::OsStr,
fs::{self},
io::Write,
};
use anyhow::Context;
use std::path::{Path, PathBuf};
// Build update
pub fn bundle_project(settings: &Settings, bundles: &[Bundle]) -> crate::Result<Vec<PathBuf>> {
if cfg!(unix) || cfg!(windows) || cfg!(macos) {
// Create our archive bundle
let bundle_result = bundle_update(settings, bundles)?;
Ok(bundle_result)
} else {
common::print_info("Current platform do not support updates")?;
Ok(vec![])
}
}
// Create simple update-macos.tar.gz
// This is the Mac OS App packaged
#[cfg(target_os = "macos")]
fn bundle_update(settings: &Settings, bundles: &[Bundle]) -> crate::Result<Vec<PathBuf>> {
// find our .app or rebuild our bundle
let bundle_path = match bundles
.iter()
.filter(|bundle| bundle.package_type == crate::PackageType::MacOSBundle)
.find_map(|bundle| {
bundle
.bundle_paths
.iter()
.find(|path| path.extension() == Some(OsStr::new("app")))
}) {
Some(path) => vec![path.clone()],
None => macos_bundle::bundle_project(settings)?,
};
// we expect our .app to be on bundle_path[0]
if bundle_path.is_empty() {
return Err(crate::Error::UnableToFindProject);
}
let source_path = &bundle_path[0];
// add .tar.gz to our path
let osx_archived = format!("{}.tar.gz", source_path.display());
let osx_archived_path = PathBuf::from(&osx_archived);
// safe unwrap
//let tar_source = &source_path.parent().unwrap().to_path_buf();
// Create our gzip file (need to send parent)
// as we walk the source directory (source isnt added)
create_tar(&source_path, &osx_archived_path)
.with_context(|| "Failed to tar.gz update directory")?;
common::print_bundling(format!("{:?}", &osx_archived_path).as_str())?;
Ok(vec![osx_archived_path])
}
// Create simple update-linux_<arch>.tar.gz
// Including the AppImage
// Right now in linux we hot replace the bin and request a restart
// No assets are replaced
#[cfg(target_os = "linux")]
fn bundle_update(settings: &Settings, bundles: &[Bundle]) -> crate::Result<Vec<PathBuf>> {
// build our app actually we support only appimage on linux
let bundle_path = match bundles
.iter()
.filter(|bundle| bundle.package_type == crate::PackageType::AppImage)
.find_map(|bundle| {
bundle
.bundle_paths
.iter()
.find(|path| path.extension() == Some(OsStr::new("AppImage")))
}) {
Some(path) => vec![path.clone()],
None => appimage_bundle::bundle_project(settings)?,
};
// we expect our .app to be on bundle[0]
if bundle_path.is_empty() {
return Err(crate::Error::UnableToFindProject);
}
let source_path = &bundle_path[0];
// add .tar.gz to our path
let appimage_archived = format!("{}.tar.gz", source_path.display());
let appimage_archived_path = PathBuf::from(&appimage_archived);
// Create our gzip file
create_tar(&source_path, &appimage_archived_path)
.with_context(|| "Failed to tar.gz update directory")?;
common::print_bundling(format!("{:?}", &appimage_archived_path).as_str())?;
Ok(vec![appimage_archived_path])
}
// Create simple update-win_<arch>.zip
// Including the binary as root
// Right now in windows we hot replace the bin and request a restart
// No assets are replaced
#[cfg(target_os = "windows")]
fn bundle_update(settings: &Settings, bundles: &[Bundle]) -> crate::Result<Vec<PathBuf>> {
// find our .msi or rebuild
let bundle_path = match bundles
.iter()
.filter(|bundle| bundle.package_type == crate::PackageType::WindowsMsi)
.find_map(|bundle| {
bundle
.bundle_paths
.iter()
.find(|path| path.extension() == Some(OsStr::new("msi")))
}) {
Some(path) => vec![path.clone()],
None => msi_bundle::bundle_project(settings)?,
};
// we expect our .msi to be on bundle_path[0]
if bundle_path.is_empty() {
return Err(crate::Error::UnableToFindProject);
}
let source_path = &bundle_path[0];
// add .tar.gz to our path
let msi_archived = format!("{}.zip", source_path.display());
let msi_archived_path = PathBuf::from(&msi_archived);
// Create our gzip file
create_zip(&source_path, &msi_archived_path).with_context(|| "Failed to zip update MSI")?;
common::print_bundling(format!("{:?}", &msi_archived_path).as_str())?;
Ok(vec![msi_archived_path])
}
#[cfg(target_os = "windows")]
pub fn create_zip(src_file: &PathBuf, dst_file: &PathBuf) -> crate::Result<PathBuf> {
let parent_dir = dst_file.parent().expect("No data in parent");
fs::create_dir_all(parent_dir)?;
let writer = common::create_file(dst_file)?;
let file_name = src_file
.file_name()
.expect("Can't extract file name from path");
let mut zip = zip::ZipWriter::new(writer);
let options = FileOptions::default()
.compression_method(zip::CompressionMethod::Stored)
.unix_permissions(0o755);
zip.start_file(file_name.to_string_lossy(), options)?;
let mut f = File::open(src_file)?;
let mut buffer = Vec::new();
f.read_to_end(&mut buffer)?;
zip.write_all(&*buffer)?;
buffer.clear();
Ok(dst_file.to_owned())
}
#[cfg(not(target_os = "windows"))]
fn create_tar(src_dir: &PathBuf, dest_path: &PathBuf) -> crate::Result<PathBuf> {
let dest_file = common::create_file(&dest_path)?;
let gzip_encoder = gzip::Encoder::new(dest_file)?;
let gzip_encoder = create_tar_from_src(src_dir, gzip_encoder)?;
let mut dest_file = gzip_encoder.finish().into_result()?;
dest_file.flush()?;
Ok(dest_path.to_owned())
}
#[cfg(not(target_os = "windows"))]
fn create_tar_from_src<P: AsRef<Path>, W: Write>(src_dir: P, dest_file: W) -> crate::Result<W> {
let src_dir = src_dir.as_ref();
let mut tar_builder = tar::Builder::new(dest_file);
// validate source type
let file_type = fs::metadata(src_dir).expect("Can't read source directory");
// if it's a file don't need to walkdir
if file_type.is_file() {
let mut src_file = fs::File::open(src_dir)?;
let file_name = src_dir
.file_name()
.expect("Can't extract file name from path");
tar_builder.append_file(file_name, &mut src_file)?;
} else {
for entry in WalkDir::new(&src_dir) {
let entry = entry?;
let src_path = entry.path();
if src_path == src_dir {
continue;
}
// todo(lemarier): better error catching
// We add the .parent() because example if we send a path
// /dev/src-tauri/target/debug/bundle/osx/app.app
// We need a tar with app.app/<...> (source root folder should be included)
let dest_path = src_path.strip_prefix(&src_dir.parent().expect(""))?;
if entry.file_type().is_dir() {
tar_builder.append_dir(dest_path, src_path)?;
} else {
let mut src_file = fs::File::open(src_path)?;
tar_builder.append_file(dest_path, &mut src_file)?;
}
}
}
let dest_file = tar_builder.into_inner()?;
Ok(dest_file)
}

View File

@ -1,6 +1,5 @@
use thiserror::Error as DeriveError;
use std::{io, num, path};
use thiserror::Error as DeriveError;
#[derive(Debug, DeriveError)]
pub enum Error {
@ -52,6 +51,9 @@ pub enum Error {
ShellScriptError(String),
#[error("`{0}`")]
GenericError(String),
/// No bundled project found for the updater.
#[error("Unable to find a bundled project for the updater")]
UnableToFindProject,
#[error("string is not UTF-8")]
Utf8(#[from] std::str::Utf8Error),
/// Windows SignTool not found.

View File

@ -1,7 +1,7 @@
#!/usr/bin/env node
const cmds = ['icon', 'deps']
const rustCliCmds = ['dev', 'build', 'init', 'info']
const rustCliCmds = ['dev', 'build', 'init', 'info', 'sign']
const cmd = process.argv[2]

View File

@ -37,5 +37,9 @@ pub fn context_codegen(data: ContextData) -> Result<TokenStream, EmbeddedAssetsE
config: #config,
assets: #assets,
default_window_icon: #default_window_icon,
package_info: ::tauri::api::PackageInfo {
name: env!("CARGO_PKG_NAME"),
version: env!("CARGO_PKG_VERSION")
}
}))
}

View File

@ -179,4 +179,8 @@ main{
.just-around{
justify-content: space-between;
}
.hidden {
display: none;
}

View File

@ -57,6 +57,14 @@
"icons/icon.ico"
]
},
"updater": {
"active": true,
"dialog": false,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5QzMxNjYwNTM5OEUwNTgKUldSWTRKaFRZQmJER1h4d1ZMYVA3dnluSjdpN2RmMldJR09hUFFlZDY0SlFqckkvRUJhZDJVZXAK",
"endpoints": [
"https://tauri-update-server.vercel.app/update/{{target}}/{{current_version}}"
]
},
"allowlist": {
"all": true
},

View File

@ -12,6 +12,7 @@
import Window from "./components/Window.svelte";
import Shortcuts from "./components/Shortcuts.svelte";
import Shell from "./components/Shell.svelte";
import Updater from "./components/Updater.svelte";
const views = [
{
@ -53,7 +54,11 @@
{
label: "Shell",
component: Shell,
}
},
{
label: "Updater",
component: Updater,
},
];
let selected = views[0];

View File

@ -0,0 +1,46 @@
<script>
// This example show how updater events work when dialog is disabled.
// This allow you to use custom dialog for the updater.
// This is your responsability to restart the application after you receive the STATUS: DONE.
import { checkUpdate, installUpdate } from "@tauri-apps/api/updater";
export let onMessage;
async function check() {
try {
document.getElementById("check_update").classList.add("hidden");
const {shouldUpdate, manifest} = await checkUpdate();
onMessage(`Should update: ${shouldUpdate}`);
onMessage(manifest);
if (shouldUpdate) {
document.getElementById("start_update").classList.remove("hidden");
}
} catch(e) {
onMessage(e);
}
}
async function install() {
try {
document.getElementById("start_update").classList.add("hidden");
await installUpdate();
onMessage("Installation complete, restart required.");
} catch(e) {
onMessage(e);
}
}
</script>
<div>
<button class="button" id="check_update" on:click={check}>Check update</button>
<button class="button hidden" id="start_update" on:click={install}>Install update</button>
</div>

View File

@ -48,6 +48,9 @@
],
"security": {
"csp": "default-src blob: data: filesystem: ws: http: https: 'unsafe-eval' 'unsafe-inline'"
},
"updater": {
"active": false
}
}
}

View File

@ -40,6 +40,9 @@
],
"security": {
"csp": "default-src blob: data: filesystem: ws: http: https: 'unsafe-eval' 'unsafe-inline'"
},
"updater": {
"active": false
}
}
}

View File

@ -0,0 +1,7 @@
{
"name": "updater",
"version": "1.0.0",
"scripts": {
"tauri": "node ../../cli/tauri.js/bin/tauri"
}
}

View File

@ -0,0 +1,329 @@
// polyfills
if (!String.prototype.startsWith) {
String.prototype.startsWith = function (searchString, position) {
position = position || 0;
return this.substr(position, searchString.length) === searchString;
};
}
(function () {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
var uid = function () {
return (
s4() +
s4() +
"-" +
s4() +
"-" +
s4() +
"-" +
s4() +
"-" +
s4() +
s4() +
s4()
);
};
function ownKeys(object, enumerableOnly) {
var keys = Object.keys(object);
if (Object.getOwnPropertySymbols) {
var symbols = Object.getOwnPropertySymbols(object);
if (enumerableOnly)
symbols = symbols.filter(function (sym) {
return Object.getOwnPropertyDescriptor(object, sym).enumerable;
});
keys.push.apply(keys, symbols);
}
return keys;
}
function _objectSpread(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i] != null ? arguments[i] : {};
if (i % 2) {
ownKeys(source, true).forEach(function (key) {
_defineProperty(target, key, source[key]);
});
} else if (Object.getOwnPropertyDescriptors) {
Object.defineProperties(
target,
Object.getOwnPropertyDescriptors(source)
);
} else {
ownKeys(source).forEach(function (key) {
Object.defineProperty(
target,
key,
Object.getOwnPropertyDescriptor(source, key)
);
});
}
}
return target;
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true,
});
} else {
obj[key] = value;
}
return obj;
}
if (!window.__TAURI__) {
window.__TAURI__ = {};
}
window.__TAURI__.transformCallback = function transformCallback(
callback,
once
) {
var identifier = uid();
window[identifier] = function (result) {
if (once) {
delete window[identifier];
}
return callback && callback(result);
};
return identifier;
};
window.__TAURI__.invoke = function invoke(cmd, args = {}) {
var _this = this;
return new Promise(function (resolve, reject) {
var callback = _this.transformCallback(function (r) {
resolve(r);
delete window[error];
}, true);
var error = _this.transformCallback(function (e) {
reject(e);
delete window[callback];
}, true);
if (typeof cmd === "string") {
args.cmd = cmd;
} else if (typeof cmd === "object") {
args = cmd;
} else {
return reject(new Error("Invalid argument type."));
}
if (window.rpc) {
window.rpc.notify(
cmd,
_objectSpread(
{
callback: callback,
error: error,
},
args
)
);
} else {
window.addEventListener("DOMContentLoaded", function () {
window.rpc.notify(
cmd,
_objectSpread(
{
callback: callback,
error: error,
},
args
)
);
});
}
});
};
// open <a href="..."> links with the Tauri API
function __openLinks() {
document.querySelector("body").addEventListener(
"click",
function (e) {
var target = e.target;
while (target != null) {
if (
target.matches ? target.matches("a") : target.msMatchesSelector("a")
) {
if (
target.href &&
target.href.startsWith("http") &&
target.target === "_blank"
) {
window.__TAURI__.invoke('tauri', {
__tauriModule: "Shell",
message: {
cmd: "open",
uri: target.href,
},
});
e.preventDefault();
}
break;
}
target = target.parentElement;
}
},
true
);
}
if (
document.readyState === "complete" ||
document.readyState === "interactive"
) {
__openLinks();
} else {
window.addEventListener(
"DOMContentLoaded",
function () {
__openLinks();
},
true
);
}
window.__TAURI__.invoke('tauri', {
__tauriModule: "Event",
message: {
cmd: "listen",
event: "tauri://window-created",
handler: window.__TAURI__.transformCallback(function (event) {
if (event.payload) {
var windowLabel = event.payload.label;
window.__TAURI__.__windows.push({ label: windowLabel });
}
}),
},
});
let permissionSettable = false;
let permissionValue = "default";
function isPermissionGranted() {
if (window.Notification.permission !== "default") {
return Promise.resolve(window.Notification.permission === "granted");
}
return window.__TAURI__.invoke('tauri', {
__tauriModule: "Notification",
message: {
cmd: "isNotificationPermissionGranted",
},
});
}
function setNotificationPermission(value) {
permissionSettable = true;
window.Notification.permission = value;
permissionSettable = false;
}
function requestPermission() {
return window.__TAURI__
.invoke('tauri', {
__tauriModule: "Notification",
mainThread: true,
message: {
cmd: "requestNotificationPermission",
},
})
.then(function (permission) {
setNotificationPermission(permission);
return permission;
});
}
function sendNotification(options) {
if (typeof options === "object") {
Object.freeze(options);
}
isPermissionGranted().then(function (permission) {
if (permission) {
return window.__TAURI__.invoke('tauri', {
__tauriModule: "Notification",
message: {
cmd: "notification",
options:
typeof options === "string"
? {
title: options,
}
: options,
},
});
}
});
}
window.Notification = function (title, options) {
var opts = options || {};
sendNotification(
Object.assign(opts, {
title: title,
})
);
};
window.Notification.requestPermission = requestPermission;
Object.defineProperty(window.Notification, "permission", {
enumerable: true,
get: function () {
return permissionValue;
},
set: function (v) {
if (!permissionSettable) {
throw new Error("Readonly property");
}
permissionValue = v;
},
});
isPermissionGranted().then(function (response) {
if (response === null) {
setNotificationPermission("default");
} else {
setNotificationPermission(response ? "granted" : "denied");
}
});
window.alert = function (message) {
window.__TAURI__.invoke('tauri', {
__tauriModule: "Dialog",
mainThread: true,
message: {
cmd: "messageDialog",
message: message,
},
});
};
window.confirm = function (message) {
return window.__TAURI__.invoke('tauri', {
__tauriModule: "Dialog",
mainThread: true,
message: {
cmd: "askDialog",
message: message,
},
});
};
})();

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to Tauri!</title>
</head>
<body>
<h1>Welcome to Tauri!</h1>
</body>
</html>

10
examples/updater/src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Generated by Cargo
# will have compiled files and executables
/target/
WixTools
# These are backup files generated by rustfmt
**/*.rs.bk
config.json
bundle.json

3791
examples/updater/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
[package]
name = "updater-example"
version = "0.1.0"
description = "A very simple Tauri Appplication"
edition = "2018"
[build-dependencies]
tauri-build = { path = "../../../core/tauri-build", features = [ "codegen" ]}
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = [ "derive" ] }
tauri = { path = "../../../tauri", features =["api-all"]}
[target."cfg(windows)".build-dependencies]
winres = "0.1"
[features]
default = [ "custom-protocol" ]
custom-protocol = [ "tauri/custom-protocol" ]
[[bin]]
name = "updater-example"
path = "src/main.rs"

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -0,0 +1,17 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
#[tauri::command]
fn my_custom_command(argument: String) {
println!("{}", argument);
}
fn main() {
tauri::AppBuilder::default()
.invoke_handler(tauri::generate_handler![my_custom_command])
.build(tauri::generate_context!())
.run()
.expect("error while running tauri application");
}

View File

@ -0,0 +1,61 @@
{
"build": {
"distDir": "../public",
"devPath": "../public",
"beforeDevCommand": "",
"beforeBuildCommand": ""
},
"tauri": {
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.tauri.dev",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [],
"externalBin": [],
"copyright": "",
"category": "DeveloperTool",
"shortDescription": "",
"longDescription": "",
"deb": {
"depends": [],
"useBootstrapper": false
},
"macOS": {
"frameworks": [],
"minimumSystemVersion": "",
"useBootstrapper": false,
"exceptionDomain": ""
}
},
"allowlist": {
"all": true
},
"windows": [
{
"title": "Welcome to Tauri!",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": "default-src blob: data: filesystem: ws: http: https: 'unsafe-eval' 'unsafe-inline'"
},
"updater": {
"active": true,
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5QzMxNjYwNTM5OEUwNTgKUldSWTRKaFRZQmJER1h4d1ZMYVA3dnluSjdpN2RmMldJR09hUFFlZDY0SlFqckkvRUJhZDJVZXAK",
"endpoints": [
"https://tauri-update-server.vercel.app/update/{{target}}/{{current_version}}"
]
}
}
}

View File

@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@ -21,6 +21,7 @@ serde_repr = "0.1"
dirs-next = "2.0.0"
zip = "0.5.11"
semver = "0.11"
ignore = "^0.4.16"
tempfile = "3"
either = "1.6.1"
tar = "0.4"

View File

@ -7,6 +7,9 @@ pub enum Error {
/// The path operation error.
#[error("Path Error: {0}")]
Path(String),
/// The path StripPrefixError error.
#[error("Path Error: {0}")]
PathPrefix(#[from] std::path::StripPrefixError),
/// Error showing the dialog.
#[error("Dialog Error: {0}")]
Dialog(String),
@ -37,6 +40,9 @@ pub enum Error {
/// IO error.
#[error("{0}")]
Io(#[from] std::io::Error),
/// Ignore error.
#[error("failed to walkdir: {0}")]
Ignore(#[from] ignore::Error),
/// ZIP error.
#[error("{0}")]
Zip(#[from] zip::result::ZipError),

View File

@ -1,3 +1,4 @@
use ignore::WalkBuilder;
use std::{fs, path};
/// Moves a file from the given path to the specified destination.
@ -33,16 +34,13 @@ impl<'a> Move<'a> {
self
}
/// Move source file to specified destination
/// Move source file to specified destination (replace whole directory)
pub fn to_dest(&self, dest: &path::Path) -> crate::Result<()> {
match self.temp {
None => {
fs::rename(self.source, dest)?;
}
Some(temp) => {
println!("dest {}", dest.to_str().unwrap());
println!("temp {}", temp.to_str().unwrap());
println!("source {}", self.source.to_str().unwrap());
if dest.exists() {
fs::rename(dest, temp)?;
if let Err(e) = fs::rename(self.source, dest) {
@ -56,4 +54,57 @@ impl<'a> Move<'a> {
};
Ok(())
}
/// Walk in the source and copy all files and create directories if needed by
/// replacing existing elements. (equivalent to a cp -R)
pub fn walk_to_dest(&self, dest: &path::Path) -> crate::Result<()> {
match self.temp {
None => {
// got no temp -- no need to backup
walkdir_and_copy(self.source, dest)?;
}
Some(temp) => {
if dest.exists() {
// we got temp and our dest exist, lets make a backup
// of current files
walkdir_and_copy(dest, temp)?;
if let Err(e) = walkdir_and_copy(self.source, dest) {
// if we got something wrong we reset the dest with our backup
fs::rename(temp, dest)?;
return Err(e);
}
} else {
// got temp but dest didnt exist
walkdir_and_copy(self.source, dest)?;
}
}
};
Ok(())
}
}
// Walk into the source and create directories, and copy files
// Overwriting existing items but keeping untouched the files in the dest
// not provided in the source.
fn walkdir_and_copy(source: &path::Path, dest: &path::Path) -> crate::Result<()> {
let walkdir = WalkBuilder::new(source).hidden(false).build();
for entry in walkdir {
// Check if it's a file
let element = entry?;
let metadata = element.metadata()?;
let destination = dest.join(element.path().strip_prefix(&source)?);
// we make sure it's a directory and destination doesnt exist
if metadata.is_dir() && !&destination.exists() {
fs::create_dir_all(&destination)?;
}
// we make sure it's a file
if metadata.is_file() {
fs::copy(element.path(), destination)?;
}
}
Ok(())
}

View File

@ -49,6 +49,15 @@ pub use error::Error;
/// Tauri API result type.
pub type Result<T> = std::result::Result<T, Error>;
/// `App` package information.
#[derive(Debug, Clone)]
pub struct PackageInfo {
/// App name.
pub name: &'static str,
/// App version.
pub version: &'static str,
}
// Not public API
#[doc(hidden)]
pub mod private {
@ -85,5 +94,6 @@ pub mod private {
fn config() -> &'static crate::config::Config;
fn assets() -> &'static crate::assets::EmbeddedAssets;
fn default_window_icon() -> Option<&'static [u8]>;
fn package_info() -> crate::PackageInfo;
}
}

View File

@ -47,3 +47,8 @@ pub fn is_patch(current: &str, other: &str) -> crate::Result<bool> {
let other = Version::parse(other)?;
Ok(current.major == other.major && current.minor == other.minor && other.patch > current.patch)
}
/// Check if a version is greater than the current
pub fn is_greater(current: &str, other: &str) -> crate::Result<bool> {
Ok(Version::parse(other)? > Version::parse(current)?)
}

View File

@ -1,7 +1,7 @@
[package]
name = "tauri-updater"
version = "0.4.2"
authors = ["Lucas Fernandes Gonçalves Nogueira <lucas@tauri.studio>", "Daniel Thompson-Yvetot <denjell@sfosc.org>", "Tensor Programming <tensordeveloper@gmail.com>"]
authors = ["Lucas Fernandes Gonçalves Nogueira <lucas@tauri.studio>", "Daniel Thompson-Yvetot <denjell@sfosc.org>", "Tensor Programming <tensordeveloper@gmail.com>", "David Lemarier <david@lemarier.ca>"]
license = "MIT"
homepage = "https://tauri.studio"
repository = "https://github.com/tauri-apps/tauri"
@ -10,13 +10,21 @@ edition = "2018"
exclude = ["test/fixture/**"]
[dependencies]
attohttpc = {version = "0.10.1", features=["json", "compress" ]}
# pbr = "1"
serde_json = "1.0"
tempfile = "3"
reqwest = { version = "0.11", features = ["json"] }
serde = "1.0"
zip = "0.5.3"
tempdir = "0.3"
tauri-api = { version = "0.5", path = "../tauri-api" }
tauri-api = { version = "0.7.2", path = "../tauri-api" }
tauri-utils = { version = "0.5", path = "../tauri-utils" }
anyhow = "1.0.31"
thiserror = "1.0.19"
semver = "0.11"
minisign-verify = "0.1.8"
base64 = "0.13.0"
# error handling
thiserror = "1.0.24"
[dev-dependencies]
tokio-test = "*"
mockito = "0.29"
[features]
default = ["reqwest/default-tls"]

328
tauri-updater/README.md Normal file
View File

@ -0,0 +1,328 @@
# Tauri Updater
---
> ⚠️ This project is a working project. Expect breaking changes.
---
The updater is focused on making Tauri's application updates **as safe and transparent as updates to a website**.
Instead of publishing a feed of versions from which your app must select, Tauri updates to the version your server tells it to. This allows you to intelligently update your clients based on the request you give to Tauri.
The server can remotely drive behaviors like rolling back or phased rollouts.
The update JSON Tauri requests should be dynamically generated based on criteria in the request, and whether an update is required.
Tauri's installer is also designed to be fault-tolerant, and ensure that any updates installed are valid and safe.
# Configuration
Once you have your Tauri project ready, you need to configure the updater.
Add this in tauri.conf.json
```json
"updater": {
"active": true,
"endpoints": [
"https://releases.myapp.com/{target}}/{current_version}}"
],
"dialog": true,
"pubkey": ""
}
```
The required keys are "active" and "endpoints", others are optional.
"active" must be a boolean. By default, it's set to false.
"endpoints" must be an array. The string `{{target}}` and `{{current_version}}` are automatically replaced in the URL allowing you determine [server-side](#update-server-json-format) if an update is available. If multiple endpoints are specified, the updater will fallback if a server is not responding within the pre-defined timeout.
"dialog" if present must be a boolean. By default, it's set to true. If enabled, [events](#events) are turned-off as the updater will handle everything. If you need the custom events, you MUST turn off the built-in dialog.
"pubkey" if present must be a valid public-key generated with Tauri cli. See [Signing updates](#signing-updates).
## Update Requests
Tauri is indifferent to the request the client application provides for update checking.
`Accept: application/json` is added to the request headers because Tauri is responsible for parsing the response.
For the requirements imposed on the responses and the body format of an update, response see [Server Support](#server-support).
Your update request must *at least* include a version identifier so that the server can determine whether an update for this specific version is required.
It may also include other identifying criteria such as operating system version, to allow the server to deliver as fine-grained an update as you would like.
How you include the version identifier or other criteria is specific to the server that you are requesting updates from. A common approach is to use query parameters, [Configuration](#configuration) shows an example of this.
## Built-in dialog
By default, updater uses a built-in dialog API from Tauri.
![New Update](https://i.imgur.com/UMilB5A.png)
The dialog release notes is represented by the update `note` provided by the [server](#server-support).
If the user accepts, the download and install are initialized. The user will be then prompted to restart the application.
## Javascript API
**Attention, you need to _disable built-in dialog_ in your [tauri configuration](#configuration), otherwise, events aren't emitted and the javascript API will NOT work.**
```
import { checkUpdate, installUpdate } from "@tauri-apps/api/updater";
try {
const {shouldUpdate, manifest} = await checkUpdate();
if (shouldUpdate) {
// display dialog
await installUpdate();
// install complete, ask to restart
}
} catch(error) {
console.log(error);
}
```
## Events
**Attention, you need to _disable built-in dialog_ in your [tauri configuration](#configuration), otherwise, events aren't emitted.**
To know when an update is ready to be installed, you can subscribe to these events:
### Initialize updater and check if a new version is available
#### If a new version is available, the event `tauri://update-available` is emitted.
Event : `tauri://update`
### Rust
```rust
dispatcher.emit("tauri://update", None);
```
### Javascript
```js
import { emit } from "@tauri-apps/api/event";
emit("tauri://update");
```
### Listen New Update Available
Event : `tauri://update-available`
Emitted data:
```
version Version announced by the server
date Date announced by the server
body Note announced by the server
```
### Rust
```rust
dispatcher.listen("tauri://update-available", move |msg| {
println("New version available: {:?}", msg);
})
```
### Javascript
```js
import { listen } from "@tauri-apps/api/event";
listen("tauri://update-available", function (res) {
console.log("New version available: ", res);
});
```
### Emit Install and Download
You need to emit this event to initialize the download and listen to the [install progress](#listen-install-progress).
Event : `tauri://update-install`
### Rust
```rust
dispatcher.emit("tauri://update-install", None);
```
### Javascript
```js
import { emit } from "@tauri-apps/api/event";
emit("tauri://update-install");
```
### Listen Install Progress
Event : `tauri://update-status`
Emitted data:
```
status [ERROR/PENDING/DONE]
error String/null
```
PENDING is emitted when the download is started and DONE when the install is complete. You can then ask to restart the application.
ERROR is emitted when there is an error with the updater. We suggest to listen to this event even if the dialog is enabled.
### Rust
```rust
dispatcher.listen("tauri://update-status", move |msg| {
println("New status: {:?}", msg);
})
```
### Javascript
```js
import { listen } from "@tauri-apps/api/event";
listen("tauri://update-status", function (res) {
console.log("New status: ", res);
});
```
# Server Support
Your server should determine whether an update is required based on the [Update Request](#update-requests) your client issues.
If an update is required your server should respond with a status code of [200 OK](http://tools.ietf.org/html/rfc2616#section-10.2.1) and include the [update JSON](#update-server-json-format) in the body. To save redundantly downloading the same version multiple times your server must not inform the client to update.
If no update is required your server must respond with a status code of [204 No Content](http://tools.ietf.org/html/rfc2616#section-10.2.5).
## Update Server JSON Format
When an update is available, Tauri expects the following schema in response to the update request provided:
```json
{
"url": "https://mycompany.example.com/myapp/releases/myrelease.tar.gz",
"version": "0.0.1",
"notes": "Theses are some release notes",
"pub_date": "2020-09-18T12:29:53+01:00",
"signature": ""
}
```
The only required keys are "url" and "version", the others are optional.
"pub_date" if present must be formatted according to ISO 8601.
"signature" if present must be a valid signature generated with Tauri cli. See [Signing updates](#signing-updates).
## Update File JSON Format
The alternate update technique uses a plain JSON file meaning you can store your update metadata on S3, gist, or another static file store. Tauri will check against the name/version field and if the version is smaller than the current one and the platform is available, the update will be triggered. The format of this file is detailed below:
```json
{
"name":"v1.0.0",
"notes":"Test version",
"pub_date":"2020-06-22T19:25:57Z",
"platforms": {
"darwin": {
"signature":"",
"url":"https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.app.tar.gz"
},
"linux": {
"signature":"",
"url":"https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.AppImage.tar.gz"
},
"win64": {
"signature":"",
"url":"https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.x64.msi.zip"
}
}
}
```
# Bundler (Artifacts)
The Tauri bundler will automatically generate update artifacts if the updater is enabled in `tauri.conf.json`
If the bundler can locate your private and pubkey, your update artifacts will be automatically signed.
The signature can be found in the `sig` file. The signature can be uploaded to GitHub safely or made public as long as your private key is secure.
You can see how it's [bundled with the CI](https://github.com/tauri-apps/tauri/blob/feature/new_updater/.github/workflows/artifacts-updater.yml#L44) and a [sample tauri.conf.json](https://github.com/tauri-apps/tauri/blob/feature/new_updater/examples/updater/src-tauri/tauri.conf.json#L52)
## macOS
On MACOS we create a .tar.gz from the whole application. (.app)
```
target/release/bundle
└── osx
└── app.app
└── app.app.tar.gz (update bundle)
└── app.app.tar.gz.sig (if signature enabled)
```
## Windows
On Windows we create a .zip from the MSI, when downloaded and validated, we run the MSI install.
```
target/release
└── app.x64.msi
└── app.x64.msi.zip (update bundle)
└── app.x64.msi.zip.sig (if signature enabled)
```
## Linux
On Linux, we create a .tar.gz from the AppImage.
```
target/release/bundle
└── appimage
└── app.AppImage
└── app.AppImage.tar.gz (update bundle)
└── app.AppImage.tar.gz.sig (if signature enabled)
```
# Signing updates
We offer a built-in signature to ensure your update is safe to be installed.
To sign your updates, you need two things.
The *Public-key* (pubkey) should be added inside your `tauri.conf.json` to validate the update archive before installing.
The *Private key* (privkey) is used to sign your update and should NEVER be shared with anyone. Also, if you lost this key, you'll NOT be able to publish a new update to the current user base (if pubkey is set in tauri.conf.json). It's important to save it at a safe place and you can always access it.
To generate your keys you need to use the Tauri cli.
```bash
tauri sign -g -w ~/.tauri/myapp.key
```
You have multiple options available
```bash
Tauri updates signer.
USAGE:
tauri sign [FLAGS] [OPTIONS]
FLAGS:
--force Overwrite private key even if it exists on the specified path
-g, --generate Generate keypair to sign files
-h, --help Prints help information
--no-password Set empty password for your private key
-V, --version Prints version information
OPTIONS:
-p, --password <password> Set private key password when signing
-k, --private-key <private-key> Load the private key from a string
-f, --private-key-path <private-key-path> Load the private key from a file
--sign-file <sign-file> Sign the specified file
-w, --write-keys <write-keys> Write private key to a file
```
***
Environment variables used to sign with `tauri-bundler`:
If they are set, and `tauri.conf.json` expose the public key, the bundler will automatically generate and sign the updater artifacts.
`TAURI_PRIVATE_KEY` Path or String of your private key
`TAURI_KEY_PASSWORD` Your private key password (optional)

View File

@ -0,0 +1,50 @@
use thiserror::Error as DeriveError;
#[derive(Debug, DeriveError)]
pub enum Error {
/// IO Errors.
#[error("`{0}`")]
Io(#[from] std::io::Error),
/// Reqwest Errors.
#[error("Request error: {0}")]
Reqwest(#[from] reqwest::Error),
/// Semver Errors.
#[error("Unable to compare version: {0}")]
Semver(#[from] semver::SemVerError),
/// JSON (Serde) Errors.
#[error("JSON error: {0}")]
SerdeJson(#[from] serde_json::Error),
/// Minisign is used for signature validation.
#[error("Verify signature error: {0}")]
Minisign(#[from] minisign_verify::Error),
/// Error with Minisign base64 decoding.
#[error("Signature decoding error: {0}")]
Base64(#[from] base64::DecodeError),
/// UTF8 Errors in signature.
#[error("Signature encoding error: {0}")]
Utf8(#[from] std::str::Utf8Error),
/// Tauri utils, mainly extract and file move.
#[error("Tauri API error: {0}")]
TauriApi(#[from] tauri_api::Error),
/// Network error.
#[error("Network error: {0}")]
Network(String),
/// Metadata (JSON) error.
#[error("Remote JSON error: {0}")]
RemoteMetadata(String),
/// Error building updater.
#[error("Unable to prepare the updater: {0}")]
Builder(String),
/// Updater is not supported for current operating system or platform.
#[error("Unsuported operating system or platform")]
UnsupportedPlatform,
/// Public key found in `tauri.conf.json` but no signature announced remotely.
#[error("Signature not available but public key provided, skipping update")]
PubkeyButNoSignature,
/// Triggered when there is NO error and the two versions are equals.
/// On client side, it's important to catch this error.
#[error("No updates available")]
UpToDate,
}
pub type Result<T = ()> = std::result::Result<T, Error>;

View File

@ -1,32 +0,0 @@
use attohttpc;
use serde::Serialize;
use std::io::{BufWriter, Write};
pub(crate) mod link_value;
pub fn get(url: String) -> crate::Result<attohttpc::Response> {
let response = attohttpc::get(url).send()?;
Ok(response)
}
pub fn post_as_json<T: Serialize>(url: String, payload: &T) -> crate::Result<attohttpc::Response> {
let response = attohttpc::post(url).json(payload)?.send()?;
Ok(response)
}
pub fn download<T: Write>(url: String, dest: T, _display_progress: bool) -> crate::Result<()> {
set_ssl_vars!();
let resp = get(url)?;
if !resp.status().is_success() {
return Err(crate::Error::Download(resp.status()).into());
}
let file = BufWriter::new(dest);
resp.write_to(file)?;
Ok(())
}

View File

@ -1,35 +0,0 @@
use std::borrow::Cow;
#[derive(Clone, PartialEq, Debug)]
pub struct LinkValue {
/// Target IRI: `link-value`.
link: Cow<'static, str>,
/// Forward Relation Types: `rel`.
rel: Option<Vec<RelationType>>,
}
impl LinkValue {
pub fn new<T>(uri: T) -> LinkValue
where
T: Into<Cow<'static, str>>,
{
LinkValue {
link: uri.into(),
rel: None,
}
}
pub fn rel(&self) -> Option<&[RelationType]> {
self.rel.as_ref().map(AsRef::as_ref)
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum RelationType {
/// next.
Next,
/// ext-rel-type.
#[allow(dead_code)]
ExtRelType(String),
}

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +0,0 @@
/// Helper to `print!` and immediately `flush` `stdout`
macro_rules! print_flush {
($literal:expr) => {
print!($literal);
::std::io::Write::flush(&mut ::std::io::stdout())?;
};
($literal:expr, $($arg:expr),*) => {
print!($literal, $($arg),*);
::std::io::Write::flush(&mut ::std::io::stdout())?;
}
}
/// Set ssl cert env. vars to make sure openssl can find required files
macro_rules! set_ssl_vars {
() => {
#[cfg(target_os = "linux")]
{
if ::std::env::var_os("SSL_CERT_FILE").is_none() {
::std::env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt");
}
if ::std::env::var_os("SSL_CERT_DIR").is_none() {
::std::env::set_var("SSL_CERT_DIR", "/etc/ssl/certs");
}
}
};
}

View File

@ -1,266 +0,0 @@
use std::env;
use std::fs;
use std::path::PathBuf;
use crate::http;
use tauri_api::file::{Extract, Move};
pub mod github;
/// Status returned after updating
///
/// Wrapped `String`s are version tags
#[derive(Debug, Clone)]
pub enum Status {
UpToDate(String),
Updated(String),
}
impl Status {
/// Return the version tag
pub fn version(&self) -> &str {
use Status::*;
match *self {
UpToDate(ref s) => s,
Updated(ref s) => s,
}
}
/// Returns `true` if `Status::UpToDate`
pub fn uptodate(&self) -> bool {
match *self {
Status::UpToDate(_) => true,
_ => false,
}
}
/// Returns `true` if `Status::Updated`
pub fn updated(&self) -> bool {
match *self {
Status::Updated(_) => true,
_ => false,
}
}
}
#[derive(Clone, Debug)]
pub struct Release {
pub version: String,
pub asset_name: String,
pub download_url: String,
}
#[derive(Debug)]
pub struct UpdateBuilder {
release: Option<Release>,
bin_name: Option<String>,
bin_install_path: Option<PathBuf>,
bin_path_in_archive: Option<PathBuf>,
show_download_progress: bool,
show_output: bool,
current_version: Option<String>,
}
impl UpdateBuilder {
/// Initialize a new builder, defaulting the `bin_install_path` to the current
/// executable's path
///
/// * Errors:
/// * Io - Determining current exe path
pub fn new() -> crate::Result<UpdateBuilder> {
Ok(Self {
release: None,
bin_name: None,
bin_install_path: Some(env::current_exe()?),
bin_path_in_archive: None,
show_download_progress: false,
show_output: true,
current_version: None,
})
}
pub fn release(&mut self, release: Release) -> &mut Self {
self.release = Some(release);
self
}
/// Set the current app version, used to compare against the latest available version.
/// The `cargo_crate_version!` macro can be used to pull the version from your `Cargo.toml`
pub fn current_version(&mut self, ver: &str) -> &mut Self {
self.current_version = Some(ver.to_owned());
self
}
/// Set the exe's name. Also sets `bin_path_in_archive` if it hasn't already been set.
pub fn bin_name(&mut self, name: &str) -> &mut Self {
self.bin_name = Some(name.to_owned());
if self.bin_path_in_archive.is_none() {
self.bin_path_in_archive = Some(PathBuf::from(name));
}
self
}
/// Set the installation path for the new exe, defaults to the current
/// executable's path
pub fn bin_install_path(&mut self, bin_install_path: &str) -> &mut Self {
self.bin_install_path = Some(PathBuf::from(bin_install_path));
self
}
/// Set the path of the exe inside the release tarball. This is the location
/// of the executable relative to the base of the tar'd directory and is the
/// path that will be copied to the `bin_install_path`. If not specified, this
/// will default to the value of `bin_name`. This only needs to be specified if
/// the path to the binary (from the root of the tarball) is not equal to just
/// the `bin_name`.
///
/// # Example
///
/// For a tarball `myapp.tar.gz` with the contents:
///
/// ```shell
/// myapp.tar/
/// |------- bin/
/// | |--- myapp # <-- executable
/// ```
///
/// The path provided should be:
///
/// ```
/// # use tauri_updater::updater::Update;
/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
/// Update::configure()?
/// .bin_path_in_archive("bin/myapp")
/// # .build()?;
/// # Ok(())
/// # }
/// ```
pub fn bin_path_in_archive(&mut self, bin_path: &str) -> &mut Self {
self.bin_path_in_archive = Some(PathBuf::from(bin_path));
self
}
/// Toggle download progress bar, defaults to `off`.
pub fn show_download_progress(&mut self, show: bool) -> &mut Self {
self.show_download_progress = show;
self
}
/// Toggle update output information, defaults to `true`.
pub fn show_output(&mut self, show: bool) -> &mut Self {
self.show_output = show;
self
}
/// Confirm config and create a ready-to-use `Update`
///
/// * Errors:
/// * Config - Invalid `Update` configuration
pub fn build(&self) -> crate::Result<Update> {
Ok(Update {
release: if let Some(ref release) = self.release {
release.to_owned()
} else {
return Err(crate::Error::Config("`release`".into()).into());
},
bin_name: if let Some(ref name) = self.bin_name {
name.to_owned()
} else {
return Err(crate::Error::Config("`bin_name`".into()).into());
},
bin_install_path: if let Some(ref path) = self.bin_install_path {
path.to_owned()
} else {
return Err(crate::Error::Config("`bin_install_path`".into()).into());
},
bin_path_in_archive: if let Some(ref path) = self.bin_path_in_archive {
path.to_owned()
} else {
return Err(crate::Error::Config("`bin_path_in_archive`".into()).into());
},
current_version: if let Some(ref ver) = self.current_version {
ver.to_owned()
} else {
return Err(crate::Error::Config("`current_version`".into()).into());
},
show_download_progress: self.show_download_progress,
show_output: self.show_output,
})
}
}
/// Updates to a specified or latest release distributed
#[derive(Debug)]
pub struct Update {
release: Release,
current_version: String,
bin_name: String,
bin_install_path: PathBuf,
bin_path_in_archive: PathBuf,
show_download_progress: bool,
show_output: bool,
}
impl Update {
/// Initialize a new `Update` builder
pub fn configure() -> crate::Result<UpdateBuilder> {
UpdateBuilder::new()
}
fn print_flush(&self, msg: &str) -> crate::Result<()> {
if self.show_output {
print_flush!("{}", msg);
}
Ok(())
}
fn println(&self, msg: &str) {
if self.show_output {
println!("{}", msg);
}
}
pub fn update(self) -> crate::Result<Status> {
self.println(&format!(
"Checking current version... v{}",
self.current_version
));
if self.show_output {
println!("\n{} release status:", self.bin_name);
println!(" * Current exe: {:?}", self.bin_install_path);
println!(" * New exe download url: {:?}", self.release.download_url);
println!(
"\nThe new release will be downloaded/extracted and the existing binary will be replaced."
);
}
let tmp_dir_parent = self
.bin_install_path
.parent()
.ok_or_else(|| crate::Error::Updater)?;
let tmp_dir =
tempdir::TempDir::new_in(&tmp_dir_parent, &format!("{}_download", self.bin_name))?;
let tmp_archive_path = tmp_dir.path().join(&self.release.asset_name);
let mut tmp_archive = fs::File::create(&tmp_archive_path)?;
self.println("Downloading...");
http::download(
self.release.download_url.clone(),
&mut tmp_archive,
self.show_download_progress,
)?;
self.print_flush("Extracting archive... ")?;
Extract::from_source(&tmp_archive_path)
.extract_file(&tmp_dir.path(), &self.bin_path_in_archive)?;
let new_exe = tmp_dir.path().join(&self.bin_path_in_archive);
self.println("Done");
self.print_flush("Replacing binary file... ")?;
let tmp_file = tmp_dir.path().join(&format!("__{}_backup", self.bin_name));
Move::from_source(&new_exe)
.replace_using_temp(&tmp_file)
.to_dest(&self.bin_install_path)?;
self.println("Done");
Ok(Status::Updated(self.release.version))
}
}

View File

@ -1,47 +0,0 @@
mod release;
pub use release::*;
use crate::http;
pub fn get_latest_release(repo_owner: &str, repo_name: &str) -> crate::Result<Release> {
set_ssl_vars!();
let api_url = format!(
"https://api.github.com/repos/{}/{}/releases/latest",
repo_owner, repo_name
);
let resp = http::get(api_url.clone())?;
if !resp.status().is_success() {
return Err(
crate::Error::Network(format!(
"api request failed with status: {:?} - for: {:?}",
resp.status(),
api_url
))
.into(),
);
}
let json = resp.json::<serde_json::Value>()?;
Ok(Release::parse(&json)?)
}
pub fn get_release_version(repo_owner: &str, repo_name: &str, ver: &str) -> crate::Result<Release> {
set_ssl_vars!();
let api_url = format!(
"https://api.github.com/repos/{}/{}/releases/tags/{}",
repo_owner, repo_name, ver
);
let resp = http::get(api_url.clone())?;
if !resp.status().is_success() {
return Err(
crate::Error::Network(format!(
"api request failed with status: {:?} - for: {:?}",
resp.status(),
api_url
))
.into(),
);
}
let json = resp.json::<serde_json::Value>()?;
Ok(Release::parse(&json)?)
}

View File

@ -1,217 +0,0 @@
use crate::http::link_value::{LinkValue, RelationType};
use serde_json;
/// GitHub release-asset information
#[derive(Clone, Debug)]
pub struct ReleaseAsset {
pub download_url: String,
pub name: String,
}
impl ReleaseAsset {
/// Parse a release-asset json object
///
/// Errors:
/// * Missing required name & browser_download_url keys
fn from_asset(asset: &serde_json::Value) -> crate::Result<ReleaseAsset> {
let download_url = asset["browser_download_url"]
.as_str()
.ok_or_else(|| crate::Error::Network("Asset missing `browser_download_url`".into()))?;
let name = asset["name"]
.as_str()
.ok_or_else(|| crate::Error::Network("Asset missing `name`".into()))?;
Ok(ReleaseAsset {
download_url: download_url.to_owned(),
name: name.to_owned(),
})
}
}
#[derive(Clone, Debug)]
pub struct Release {
pub name: String,
pub body: String,
pub tag: String,
pub date_created: String,
pub assets: Vec<ReleaseAsset>,
}
impl Release {
pub fn parse(release: &serde_json::Value) -> crate::Result<Release> {
let tag = release["tag_name"]
.as_str()
.ok_or_else(|| crate::Error::Network("Release missing `tag_name`".into()))?;
let date_created = release["created_at"]
.as_str()
.ok_or_else(|| crate::Error::Network("Release missing `created_at`".into()))?;
let name = release["name"].as_str().unwrap_or(tag);
let body = release["body"].as_str().unwrap_or("");
let assets = release["assets"]
.as_array()
.ok_or_else(|| crate::Error::Network("No assets found".into()))?;
let assets = assets
.iter()
.map(ReleaseAsset::from_asset)
.collect::<crate::Result<Vec<ReleaseAsset>>>()?;
Ok(Release {
name: name.to_owned(),
body: body.to_owned(),
tag: tag.to_owned(),
date_created: date_created.to_owned(),
assets,
})
}
/// Check if release has an asset who's name contains the specified `target`
pub fn has_target_asset(&self, target: &str) -> bool {
self.assets.iter().any(|asset| asset.name.contains(target))
}
/// Return the first `ReleaseAsset` for the current release who's name
/// contains the specified `target`
pub fn asset_for(&self, target: &str) -> Option<ReleaseAsset> {
self
.assets
.iter()
.filter(|asset| asset.name.contains(target))
.cloned()
.nth(0)
}
pub fn version(&self) -> &str {
self.tag.trim_start_matches('v')
}
}
/// `ReleaseList` Builder
#[derive(Clone, Debug)]
pub struct ReleaseListBuilder {
repo_owner: Option<String>,
repo_name: Option<String>,
target: Option<String>,
}
impl ReleaseListBuilder {
/// Set the repo owner, used to build a github api url
pub fn repo_owner(&mut self, owner: &str) -> &mut Self {
self.repo_owner = Some(owner.to_owned());
self
}
/// Set the repo name, used to build a github api url
pub fn repo_name(&mut self, name: &str) -> &mut Self {
self.repo_name = Some(name.to_owned());
self
}
/// Set the optional arch `target` name, used to filter available releases
pub fn target(&mut self, target: &str) -> &mut Self {
self.target = Some(target.to_owned());
self
}
/// Verify builder args, returning a `ReleaseList`
pub fn build(&self) -> crate::Result<ReleaseList> {
Ok(ReleaseList {
repo_owner: if let Some(ref owner) = self.repo_owner {
owner.to_owned()
} else {
return Err(crate::Error::Config("`repo_owner`".into()).into());
},
repo_name: if let Some(ref name) = self.repo_name {
name.to_owned()
} else {
return Err(crate::Error::Config("`repo_name`".into()).into());
},
target: self.target.clone(),
})
}
}
/// `ReleaseList` provides a builder api for querying a GitHub repo,
/// returning a `Vec` of available `Release`s
#[derive(Clone, Debug)]
pub struct ReleaseList {
repo_owner: String,
repo_name: String,
target: Option<String>,
}
impl ReleaseList {
/// Initialize a ReleaseListBuilder
pub fn configure() -> ReleaseListBuilder {
ReleaseListBuilder {
repo_owner: None,
repo_name: None,
target: None,
}
}
/// Retrieve a list of `Release`s.
/// If specified, filter for those containing a specified `target`
pub fn fetch(self) -> crate::Result<Vec<Release>> {
set_ssl_vars!();
let api_url = format!(
"https://api.github.com/repos/{}/{}/releases",
self.repo_owner, self.repo_name
);
let releases = Self::fetch_releases(&api_url)?;
let releases = match self.target {
None => releases,
Some(ref target) => releases
.into_iter()
.filter(|r| r.has_target_asset(target))
.collect::<Vec<_>>(),
};
Ok(releases)
}
fn fetch_releases(url: &str) -> crate::Result<Vec<Release>> {
let (status, headers, reader) = attohttpc::get(url).send()?.split();
if !status.is_success() {
return Err(
crate::Error::Network(format!(
"api request failed with status: {:?} - for: {:?}",
status, url
))
.into(),
);
}
let releases = reader.json::<serde_json::Value>()?;
let releases = releases
.as_array()
.ok_or_else(|| crate::Error::Network("No releases found".into()))?;
let mut releases = releases
.iter()
.map(Release::parse)
.collect::<crate::Result<Vec<Release>>>()?;
// handle paged responses containing `Link` header:
// `Link: <https://api.github.com/resource?page=2>; rel="next"`
let links = headers.get_all(attohttpc::header::LINK);
let next_link = links
.iter()
.filter_map(|link| {
if let Ok(link) = link.to_str() {
let lv = LinkValue::new(link.to_owned());
if let Some(rels) = lv.rel() {
if rels.contains(&RelationType::Next) {
return Some(link);
}
}
None
} else {
None
}
})
.nth(0);
Ok(match next_link {
None => releases,
Some(link) => {
releases.extend(Self::fetch_releases(link)?);
releases
}
})
}
}

Binary file not shown.

View File

@ -0,0 +1 @@
dW90cnVzdGVkIGIvbW1lbnQ6IHNpZ25hdHVyZSBmcm1tIHRhdXJpIHNlY3JldCBrZXkKUldUTE3QzWxkQolZOVVDaC92ZnhXN0IrVm4rVW9GKzdoSFF6NEtFc3J3c004YUhQTFR0Njg5MGtuZkZqeVh1cTlwZ1dmWG9aSkx5d0t1WTBkS04wK1RBeEI2K2pka2tsT3drPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkxODg2NjU2CWZpbGU6L1VzZXJzL2RhdmlkL2Rldi90YXVyaV91cGRhdGVyL3RhdXJpLXVwZGF0ZXIvdGVzdC9maXh0dXJlL2FyY2hpdmVzL2FyY2hpdmUudGFyLmd6CjNmMC9XdmYyzDtCM3ZoaWhEbHVVL08vV2tLejQ0Wlg5SkNGTys2N1ZMTHQvUENrK0svMlgzNE22UkQ0OG1sZ0RqTGZXNzA0OGxocmg4ODljM3BGOEJnPT0K

View File

@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldSWTRKaFRZQmJER1VrQzlhaWFLOExCM3VoV1gxTW1IU0ZQQTZKTXN6U0MwMDEzcThyc3R3a0pmVkJrdWFhN1JCOTNpaEg5c1JhSGY0QWxROENlbTBxbFhpVTNWUWViWVFBPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNjE1Mzk5ODg3CWZpbGU6Li4vLi4vdGF1cmktdXBkYXRlci90ZXN0L2ZpeHR1cmUvYXJjaGl2ZXMvYXJjaGl2ZS50YXIuZ3oKczI0cUxjM2YvMGdpMDI1ejE3ZnREQTNtdlBwR2xydW5rVFBvcUxVcUt2dVpxZkpPd1ZLT1Z1K0hZS0hjRVk3Ylg4UVQxWEtWMHJ4ZUwwSXcvZjlaQmc9PQo=

Binary file not shown.

View File

@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVVZN3dSRm5KTmZPMlovQjAxdGtCN3YxYWJybFM2RzlxRm1rMnNkc20rbWRrS2d4ZlNJT0F4RUdrTFVGYWlUQVMxUE1VRk1uNW5IS2tucHNYNnRLZGdFPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkxODg2NjIzCWZpbGU6L1VzZXJzL2RhdmlkL2Rldi90YXVyaV91cGRhdGVyL3RhdXJpLXVwZGF0ZXIvdGVzdC9maXh0dXJlL2FyY2hpdmVzL2FyY2hpdmUuemlwCkNiNkJBKzNkR2M3WFhGTDgwSnB6NnpSYUl0VUFKVFNyRTJGVEdFbVkrNXZPTHBOQU1UeWIxbTB5QVpLRTBoM1NHeGs0RGxvTFRKY0FoWmVIS2psNkJBPT0K

View File

@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5dGlHbTEvRFhRRis2STdlTzF3eWhOVk9LNjdGRENJMnFSREE3R2V3b3Rwb0FBQkFBQUFBQUFBQUFBQUlBQUFBQWFNZEJTNXFuVjk0bmdJMENRRXVYNG5QVzBDd1NMOWN4Q2RKRXZxRDZNakw3Y241Vkt3aTg2WGtoajJGS1owV0ZuSmo4ZXJ0ZCtyaWF0RWJObFpnd1EveDB4NzBTU2RweG9ZaUpuc3hnQ3BYVG9HNnBXUW5SZ2Q3b3dvZ3Y2UnhQZ1BQZDU3bXl6d3M9Cg==

View File

@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY1OTgwQzc0UjVGNjM0Q0IKUldUTE5QWWxkQnlZOWFBK21kekU4OGgzdStleEtkeStHaFR5NjEyRHovRnlUdzAwWGJxWEU2aGYK

View File

@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5YTBGV3JiTy9lRDZVd3NkL0RoQ1htZmExNDd3RmJaNmRMT1ZGVjczWTBKZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQWdMekUzVkE4K0tWQ1hjeGt1Vkx2QnRUR3pzQjVuV0ZpM2czWXNkRm9hVUxrVnB6TUN3K1NheHJMREhQbUVWVFZRK3NIL1VsMDBHNW5ET1EzQno0UStSb21nRW4vZlpTaXIwZFh5ZmRlL1lSN0dKcHdyOUVPclVvdzFhVkxDVnZrbHM2T1o4Tk1NWEU9Cg==

View File

@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5QzMxNjYwNTM5OEUwNTgKUldSWTRKaFRZQmJER1h4d1ZMYVA3dnluSjdpN2RmMldJR09hUFFlZDY0SlFqckkvRUJhZDJVZXAK

View File

@ -155,6 +155,39 @@ impl Default for WindowConfig {
}
}
/// The Updater configuration object.
#[derive(PartialEq, Deserialize, Debug, Clone)]
#[serde(tag = "updater", rename_all = "camelCase")]
pub struct UpdaterConfig {
/// Whether the updater is active or not.
#[serde(default)]
pub active: bool,
/// Display built-in dialog or use event system if disabled.
#[serde(default = "default_updater_dialog")]
pub dialog: bool,
/// The updater endpoints.
#[serde(default)]
pub endpoints: Option<Vec<String>>,
/// Optional pubkey.
#[serde(default)]
pub pubkey: Option<String>,
}
fn default_updater_dialog() -> bool {
true
}
impl Default for UpdaterConfig {
fn default() -> Self {
Self {
active: false,
dialog: true,
endpoints: None,
pubkey: None,
}
}
}
/// A CLI argument definition
#[derive(PartialEq, Deserialize, Debug, Default, Clone)]
#[serde(rename_all = "camelCase")]
@ -324,6 +357,9 @@ pub struct TauriConfig {
/// The bundler configuration.
#[serde(default)]
pub bundle: BundleConfig,
/// The updater configuration.
#[serde(default)]
pub updater: UpdaterConfig,
}
impl Default for TauriConfig {
@ -332,6 +368,7 @@ impl Default for TauriConfig {
windows: default_window_config(),
cli: None,
bundle: BundleConfig::default(),
updater: UpdaterConfig::default(),
}
}
}
@ -711,13 +748,25 @@ mod build {
}
}
impl ToTokens for UpdaterConfig {
fn to_tokens(&self, tokens: &mut TokenStream) {
let active = self.active;
let dialog = self.dialog;
let pubkey = opt_str_lit(self.pubkey.as_ref());
let endpoints = opt_vec_str_lit(self.endpoints.as_ref());
literal_struct!(tokens, UpdaterConfig, active, dialog, pubkey, endpoints);
}
}
impl ToTokens for TauriConfig {
fn to_tokens(&self, tokens: &mut TokenStream) {
let windows = vec_lit(&self.windows, identity);
let cli = opt_lit(self.cli.as_ref());
let bundle = &self.bundle;
let updater = &self.updater;
literal_struct!(tokens, TauriConfig, windows, cli, bundle);
literal_struct!(tokens, TauriConfig, windows, cli, bundle, updater);
}
}
@ -765,6 +814,8 @@ mod test {
let d_title = default_title();
// get default bundle
let d_bundle = BundleConfig::default();
// get default updater
let d_updater = UpdaterConfig::default();
// create a tauri config.
let tauri = TauriConfig {
@ -792,6 +843,12 @@ mod test {
identifier: String::from(""),
},
cli: None,
updater: UpdaterConfig {
active: false,
dialog: true,
pubkey: None,
endpoints: None,
},
};
// create a build config
@ -805,6 +862,7 @@ mod test {
assert_eq!(t_config, tauri);
assert_eq!(b_config, build);
assert_eq!(d_bundle, tauri.bundle);
assert_eq!(d_updater, tauri.updater);
assert_eq!(d_path, String::from("http://localhost:8080"));
assert_eq!(d_title, tauri.windows[0].title);
assert_eq!(d_windows, tauri.windows);

View File

@ -27,6 +27,7 @@ uuid = { version = "0.8.2", features = [ "v4" ] }
thiserror = "1.0.24"
once_cell = "1.7.2"
tauri-api = { version = "0.7.5", path = "../tauri-api" }
tauri-updater = { version = "0.4.2", optional = true, path = "../tauri-updater" }
tauri-macros = { version = "0.1", path = "../tauri-macros" }
wry = "0.7"
rand = "0.8"
@ -43,8 +44,8 @@ serde = { version = "1.0", features = [ "derive" ] }
[features]
cli = [ "tauri-api/cli" ]
custom-protocol = [ ]
api-all = [ "tauri-api/notification", "tauri-api/global-shortcut" ]
updater = [ ]
api-all = [ "tauri-api/notification", "tauri-api/global-shortcut", "updater" ]
updater = [ "tauri-updater" ]
# FS
fs-all = [ ]

View File

@ -49,6 +49,9 @@ pub enum Error {
/// Encountered an error in the setup hook,
#[error("error encountered during setup hood: {0}")]
Setup(#[from] Box<dyn std::error::Error>),
/// Tauri updater error.
#[error("Updater: {0}")]
TauriUpdater(#[from] tauri_updater::Error),
}
impl From<serde_json::Error> for Error {

View File

@ -7,6 +7,7 @@ use crate::{
manager::WindowManager,
sealed::ManagerPrivate,
tag::Tag,
updater,
webview::{Attributes, WindowConfig},
window::{PendingWindow, Window},
Context, Dispatch, Manager, Params, Runtime, RuntimeOrDispatch,
@ -19,6 +20,65 @@ pub struct App<M: Params> {
manager: M,
}
#[cfg(feature = "updater")]
impl<M: Params> App<M> {
/// Runs the updater hook with built-in dialog.
fn run_updater_dialog(&self, window: Window<M>) {
let updater_config = self.manager.config().tauri.updater.clone();
let package_info = self.manager.package_info().clone();
crate::async_runtime::spawn(async move {
updater::check_update_with_dialog(updater_config, package_info, window).await
});
}
/// Listen updater events when dialog are disabled.
fn listen_updater_events(&self, window: Window<M>) {
let updater_config = self.manager.config().tauri.updater.clone();
updater::listener(updater_config, self.manager.package_info().clone(), &window);
}
fn run_updater(&self, main_window: Option<Window<M>>) {
if let Some(main_window) = main_window {
let event_window = main_window.clone();
let updater_config = self.manager.config().tauri.updater.clone();
// check if updater is active or not
if updater_config.dialog && updater_config.active {
// if updater dialog is enabled spawn a new task
self.run_updater_dialog(main_window.clone());
let config = self.manager.config().tauri.updater.clone();
let package_info = self.manager.package_info().clone();
// When dialog is enabled, if user want to recheck
// if an update is available after first start
// invoke the Event `tauri://update` from JS or rust side.
main_window.listen(
updater::EVENT_CHECK_UPDATE
.parse()
.unwrap_or_else(|_| panic!("bad label")),
move |_msg| {
let window = event_window.clone();
let package_info = package_info.clone();
let config = config.clone();
// re-spawn task inside tokyo to launch the download
// we don't need to emit anything as everything is handled
// by the process (user is asked to restart at the end)
// and it's handled by the updater
crate::async_runtime::spawn(async move {
updater::check_update_with_dialog(config, package_info, window).await
});
},
);
} else if updater_config.active {
// we only listen for `tauri://update`
// once we receive the call, we check if an update is available or not
// if there is a new update we emit `tauri://update-available` with details
// this is the user responsabilities to display dialog and ask if user want to install
// to install the update you need to invoke the Event `tauri://update-install`
self.listen_updater_events(main_window);
}
}
}
}
impl<M: Params> Manager<M> for App<M> {}
impl<M: Params> ManagerPrivate<M> for App<M> {
fn manager(&self) -> &M {
@ -66,12 +126,22 @@ impl<M: Params> Runner<M> {
};
let pending_windows = self.pending_windows;
#[cfg(feature = "updater")]
let mut main_window = None;
for pending in pending_windows {
let pending = app.manager.prepare_window(pending, &labels)?;
let detached = app.runtime.create_window(pending)?;
app.manager.attach_window(detached);
let window = app.manager.attach_window(detached);
#[cfg(feature = "updater")]
if main_window.is_none() {
main_window = Some(window);
}
}
#[cfg(feature = "updater")]
app.run_updater(main_window);
(self.setup)(&mut app)?;
app.runtime.run();
Ok(())

View File

@ -2,6 +2,7 @@ use crate::{
api::{
assets::Assets,
config::{Config, WindowUrl},
PackageInfo,
},
event::{Event, EventHandler, Listeners},
hooks::{InvokeHandler, InvokeMessage, InvokePayload, OnPageLoad, PageLoadPayload},
@ -44,6 +45,7 @@ pub struct InnerWindowManager<M: Params> {
/// A list of salts that are valid for the current application.
salts: Mutex<HashSet<Uuid>>,
package_info: PackageInfo,
}
pub struct WindowManager<E, L, A, R>
@ -93,6 +95,7 @@ where
assets: Arc::new(context.assets),
default_window_icon: context.default_window_icon,
salts: Mutex::default(),
package_info: context.package_info,
}),
}
}
@ -159,6 +162,24 @@ where
}
}
// If we are on windows use App Data Local as webview temp dir
// to prevent any bundled application to failed.
// Fix: https://github.com/tauri-apps/tauri/issues/1365
#[cfg(windows)]
{
// Should return a path similar to C:\Users\<User>\AppData\Local\<AppName>
let local_app_data = tauri_api::path::resolve_path(
self.inner.package_info.name,
Some(tauri_api::path::BaseDirectory::LocalData),
);
// Make sure the directory exist without panic
if let Ok(user_data_dir) = local_app_data {
if let Ok(()) = std::fs::create_dir_all(&user_data_dir) {
attributes = attributes.user_data_path(Some(user_data_dir));
}
}
}
Ok(attributes)
}
@ -484,6 +505,10 @@ where
&self.inner.config
}
fn package_info(&self) -> &PackageInfo {
&self.inner.package_info
}
fn unlisten(&self, handler_id: EventHandler) {
self.inner.listeners.unlisten(handler_id)
}

View File

@ -13,6 +13,8 @@ pub(crate) mod app;
pub mod flavor;
pub(crate) mod manager;
pub(crate) mod tag;
#[cfg(feature = "updater")]
pub(crate) mod updater;
pub(crate) mod webview;
pub(crate) mod window;
@ -28,6 +30,9 @@ pub struct Context<A: Assets> {
/// The default window icon Tauri should use when creating windows.
pub default_window_icon: Option<Vec<u8>>,
/// Package information.
pub package_info: tauri_api::PackageInfo,
}
/// The webview runtime interface.
@ -140,7 +145,7 @@ pub trait Dispatch: Clone + Send + Sized + 'static {
pub(crate) mod sealed {
use super::Params;
use crate::{
api::config::Config,
api::{config::Config, PackageInfo},
event::{Event, EventHandler},
hooks::{InvokeMessage, PageLoadPayload},
runtime::{
@ -202,6 +207,9 @@ pub(crate) mod sealed {
/// The configuration the [`Manager`] was built with.
fn config(&self) -> &Config;
/// App package information.
fn package_info(&self) -> &PackageInfo;
/// Remove the specified event handler.
fn unlisten(&self, handler_id: EventHandler);

View File

@ -0,0 +1,285 @@
use crate::{
api::{
config::UpdaterConfig,
dialog::{ask, AskResponse},
},
runtime::{window::Window, Params},
};
use std::{
path::PathBuf,
process::{exit, Command},
};
// Check for new updates
pub const EVENT_CHECK_UPDATE: &str = "tauri://update";
// New update available
pub const EVENT_UPDATE_AVAILABLE: &str = "tauri://update-available";
// Used to intialize an update *should run check-update first (once you received the update available event)*
pub const EVENT_INSTALL_UPDATE: &str = "tauri://update-install";
// Send updater status or error even if dialog is enabled, you should
// always listen for this event. It'll send you the install progress
// and any error triggered during update check and install
pub const EVENT_STATUS_UPDATE: &str = "tauri://update-status";
// this is the status emitted when the download start
pub const EVENT_STATUS_PENDING: &str = "PENDING";
// When you got this status, something went wrong
// you can find the error message inside the `error` field.
pub const EVENT_STATUS_ERROR: &str = "ERROR";
// When you receive this status, you should ask the user to restart
pub const EVENT_STATUS_SUCCESS: &str = "DONE";
// When you receive this status, this is because the application is runniing last version
pub const EVENT_STATUS_UPTODATE: &str = "UPTODATE";
#[derive(Clone, serde::Serialize)]
struct StatusEvent {
status: String,
error: Option<String>,
}
#[derive(Clone, serde::Serialize)]
struct UpdateManifest {
version: String,
date: String,
body: String,
}
/// Check if there is any new update with builtin dialog.
pub(crate) async fn check_update_with_dialog<M: Params>(
updater_config: UpdaterConfig,
package_info: crate::api::PackageInfo,
window: Window<M>,
) {
if !updater_config.active || updater_config.endpoints.is_none() {
return;
}
// prepare our endpoints
let endpoints = updater_config
.endpoints
.as_ref()
// this expect can lead to a panic
// we should have a better handling here
.expect("Something wrong with endpoints")
.clone();
// check updates
match tauri_updater::builder()
.urls(&endpoints[..])
.current_version(package_info.version)
.build()
.await
{
Ok(updater) => {
let pubkey = updater_config.pubkey.clone();
// if dialog enabled only
if updater.should_update && updater_config.dialog {
let body = updater.body.clone().unwrap_or_else(|| String::from(""));
let dialog =
prompt_for_install(&updater.clone(), package_info.name, &body.clone(), pubkey).await;
if dialog.is_err() {
send_status_update(
window.clone(),
EVENT_STATUS_ERROR,
Some(dialog.err().unwrap().to_string()),
);
return;
}
}
}
Err(e) => {
send_status_update(window.clone(), EVENT_STATUS_ERROR, Some(e.to_string()));
}
}
}
/// Experimental listener
/// This function should be run on the main thread once.
pub(crate) fn listener<M: Params>(
updater_config: UpdaterConfig,
package_info: crate::api::PackageInfo,
window: &Window<M>,
) {
let isolated_window = window.clone();
// Wait to receive the event `"tauri://update"`
window.listen(
EVENT_CHECK_UPDATE
.parse()
.unwrap_or_else(|_| panic!("bad label")),
move |_msg| {
let window = isolated_window.clone();
let package_info = package_info.clone();
// prepare our endpoints
let endpoints = updater_config
.endpoints
.as_ref()
.expect("Something wrong with endpoints")
.clone();
let pubkey = updater_config.pubkey.clone();
// check updates
crate::async_runtime::spawn(async move {
let window = window.clone();
let window_isolation = window.clone();
let pubkey = pubkey.clone();
match tauri_updater::builder()
.urls(&endpoints[..])
.current_version(package_info.version)
.build()
.await
{
Ok(updater) => {
// send notification if we need to update
if updater.should_update {
let body = updater.body.clone().unwrap_or_else(|| String::from(""));
// Emit `tauri://update-available`
let _ = window.emit(
&EVENT_UPDATE_AVAILABLE
.parse()
.unwrap_or_else(|_| panic!("bad label")),
Some(UpdateManifest {
body,
date: updater.date.clone(),
version: updater.version.clone(),
}),
);
// Listen for `tauri://update-install`
window.once(
EVENT_INSTALL_UPDATE
.parse()
.unwrap_or_else(|_| panic!("bad label")),
move |_msg| {
let window = window_isolation.clone();
let updater = updater.clone();
let pubkey = pubkey.clone();
// Start installation
crate::async_runtime::spawn(async move {
// emit {"status": "PENDING"}
send_status_update(window.clone(), EVENT_STATUS_PENDING, None);
// Launch updater download process
// macOS we display the `Ready to restart dialog` asking to restart
// Windows is closing the current App and launch the downloaded MSI when ready (the process stop here)
// Linux we replace the AppImage by launching a new install, it start a new AppImage instance, so we're closing the previous. (the process stop here)
let update_result = updater.clone().download_and_install(pubkey.clone()).await;
if update_result.is_err() {
// emit {"status": "ERROR", "error": "The error message"}
send_status_update(
window.clone(),
EVENT_STATUS_ERROR,
Some(update_result.err().unwrap().to_string()),
);
} else {
// emit {"status": "DONE"}
// todo(lemarier): maybe we should emit the
// path of the current EXE so they can restart it
send_status_update(window.clone(), EVENT_STATUS_SUCCESS, None);
}
})
},
);
} else {
send_status_update(window.clone(), EVENT_STATUS_UPTODATE, None);
}
}
Err(e) => {
send_status_update(window.clone(), EVENT_STATUS_ERROR, Some(e.to_string()));
}
}
})
},
);
}
// Send a status update via `tauri://update-status` event.
fn send_status_update<M: Params>(window: Window<M>, status: &str, error: Option<String>) {
let _ = window.emit_internal(
EVENT_STATUS_UPDATE.to_string(),
Some(StatusEvent {
error,
status: String::from(status),
}),
);
}
// Prompt a dialog asking if the user want to install the new version
// Maybe we should add an option to customize it in future versions.
async fn prompt_for_install(
updater: &tauri_updater::Update,
app_name: &str,
body: &str,
pubkey: Option<String>,
) -> crate::Result<()> {
// remove single & double quote
let escaped_body = body.replace(&['\"', '\''][..], "");
// todo(lemarier): We should review this and make sure we have
// something more conventional.
let should_install = ask(
format!(r#"A new version of {} is available! "#, app_name),
format!(
r#"{} {} is now available -- you have {}.
Would you like to install it now?
Release Notes:
{}"#,
app_name, updater.version, updater.current_version, escaped_body,
),
);
match should_install {
AskResponse::Yes => {
// Launch updater download process
// macOS we display the `Ready to restart dialog` asking to restart
// Windows is closing the current App and launch the downloaded MSI when ready (the process stop here)
// Linux we replace the AppImage by launching a new install, it start a new AppImage instance, so we're closing the previous. (the process stop here)
updater.download_and_install(pubkey.clone()).await?;
// Ask user if we need to restart the application
let should_exit = ask(
"Ready to Restart",
"The installation was successful, do you want to restart the application now?",
);
match should_exit {
AskResponse::Yes => {
restart_application(updater.current_binary.as_ref());
// safely exit even if the process
// should be killed
return Ok(());
}
AskResponse::No => {
// Do nothing -- maybe we can emit some event here
}
}
}
AskResponse::No => {
// Do nothing -- maybe we can emit some event here
}
}
Ok(())
}
// Tested on macOS and Linux. Windows will not trigger the dialog
// as it'll exit before, to launch the MSI installation.
fn restart_application(binary_to_start: Option<&PathBuf>) {
// spawn new process
if let Some(path) = binary_to_start {
Command::new(path)
.spawn()
.expect("application failed to start");
}
exit(0);
}

View File

@ -19,6 +19,9 @@
],
"security": {
"csp": "default-src blob: data: filesystem: ws: http: https: 'unsafe-eval' 'unsafe-inline'"
},
"updater": {
"active": false
}
}
}