├── .DISABLE.travis.yml ├── .github └── workflows │ ├── lint.yml │ ├── main.yml │ └── run_tests.sh ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── README.md ├── appveyor.yml ├── src ├── data │ └── mod.rs ├── lib.rs └── updater │ ├── imp.rs │ ├── mod.rs │ ├── releaser.rs │ └── tests.rs └── tests └── latest.json /.DISABLE.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | rust: 3 | - stable 4 | - nightly 5 | cache: cargo 6 | script: 7 | - cargo build --features ci 8 | - cargo test --features ci -- --nocapture --test-threads=1 9 | matrix: 10 | allow_failures: 11 | - rust: nightly 12 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: clippy 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | Lint: 14 | runs-on: ubuntu-latest 15 | if: ${{ github.ref_type != 'tag' }} 16 | steps: 17 | - name: Checkout repo 18 | uses: actions/checkout@v2 19 | - name: Setup Rust linting tools 20 | uses: actions-rs/toolchain@v1 21 | with: 22 | profile: minimal 23 | toolchain: stable 24 | override: true 25 | components: rustfmt, clippy 26 | - name: Run cargo fmt 27 | uses: actions-rs/cargo@v1 28 | with: 29 | command: fmt 30 | args: --all -- --check 31 | - name: Run full pedantic clippy lints 32 | uses: actions-rs/cargo@v1 33 | with: 34 | command: clippy 35 | args: --tests --workspace -- -Dclippy::all -Dclippy::pedantic -D warnings 36 | 37 | # Audit: 38 | # runs-on: ubuntu-latest 39 | # steps: 40 | # - uses: actions/checkout@v1 41 | # - uses: EmbarkStudios/cargo-deny-action@v1 42 | 43 | 44 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | tags: 9 | - '*' 10 | # pull_request: 11 | # branches: 12 | # - master 13 | 14 | env: 15 | RELEASE_COMMIT: ${{ github.ref_type == 'tag' }} 16 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 17 | RUST_LOG: 'debug' 18 | 19 | 20 | jobs: 21 | Build: 22 | name: Tests 23 | if: ${{ github.ref_type != 'tag' }} 24 | strategy: 25 | matrix: 26 | os: [ubuntu-latest, macos-latest] 27 | rust: [1.56.0, stable] 28 | exclude: 29 | - os: ubuntu-latest 30 | rust: 1.56.0 31 | # - os: windows-latest 32 | # rust: 1.56.0 33 | # - os: ubuntu-latest 34 | # rust: nightly 35 | # - os: windows-latest 36 | # rust: nightly 37 | 38 | runs-on: ${{ matrix.os }} 39 | # runs-on: self-hosted 40 | 41 | steps: 42 | - name: Checkout repo 43 | uses: actions/checkout@v2 44 | - name: Setup Rust toolchain 45 | uses: actions-rs/toolchain@v1 46 | with: 47 | profile: minimal 48 | toolchain: ${{ matrix.rust }} 49 | override: true 50 | - name: Build (${{ matrix.os }}-${{ matrix.rust }}) 51 | uses: actions-rs/cargo@v1 52 | with: 53 | command: build 54 | - name: Set RUST_TEST_{NOCAPTURE, THREADS} 55 | run: echo "RUST_TEST_NOCAPTURE=1" >> $GITHUB_ENV && echo "RUST_TEST_THREADS=1" >> $GITHUB_ENV 56 | - name: Run module tests (${{ matrix.os }}-${{ matrix.rust }}) 57 | uses: actions-rs/cargo@v1 58 | with: 59 | command: test 60 | args: --features updater --lib 61 | - name: Run doc tests (${{ matrix.os }}-${{ matrix.rust }}) 62 | uses: actions-rs/cargo@v1 63 | with: 64 | command: test 65 | args: --features updater --doc 66 | Publish: 67 | runs-on: macos-latest 68 | if: ${{ github.ref_type == 'tag' }} 69 | steps: 70 | - name: Checkout repo 71 | uses: actions/checkout@v2 72 | - name: Setup Rust tools 73 | uses: actions-rs/toolchain@v1 74 | with: 75 | profile: minimal 76 | toolchain: stable 77 | override: true 78 | # - name: Publish to crates.io 79 | # uses: actions-rs/cargo@v1 80 | # with: 81 | # command: publish 82 | # args: --all-features 83 | - name: Publish to crates.io 84 | run: CARGO_REGISTRY_TOKEN=${{ secrets.CARGO_REGISTRY_TOKEN }} cargo publish --all-features 85 | -------------------------------------------------------------------------------- /.github/workflows/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export RUST_TEST_NOCAPTURE=1 4 | if [ -n "$CARGO_REGISTRY_TOKEN" ]; then 5 | echo "${CARGO_REGISTRY_TOKEN:1:3}" 6 | else 7 | echo "didn't get CARGO_REGISTRY_TOKEN!" 8 | fi 9 | exit 10 | 11 | cargo test --features updater --lib -- --test-threads=1 12 | # cargo test --features updater,ci --lib -- --test-threads=1 13 | 14 | # cargo test --features updater --lib -- --ignored --test-threads=1 15 | # cargo test --features updater,ci --lib -- --ignored --test-threads=1 16 | 17 | # doc tests 18 | cargo test --features updater --doc 19 | # cargo test --features updater,ci --doc 20 | 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.7.1] - 2022-07-10 10 | ### Changed 11 | - Add a user-agent to reqwest::Client 12 | 13 | ## [0.7.0] - 2022-07-09 14 | ### Changed 15 | - Replace failure crate with anyhow for error handling. 16 | - Upgrade/Update reqwest, url crate 17 | - Update some docs 18 | - minimum rustc version is 1.56 19 | - Bump version of dependencies in Cargo.toml 20 | - Use github's actions for CI test. 21 | 22 | ## [0.6.0] - 2021-06-01 23 | ### Changed 24 | - Clean-up tests. 25 | - GithubReleaser now prefers urls ending with .alfredworkflow 26 | 27 | ## [0.5.1] - 2019-02-24 28 | ### Changed 29 | - **Breaking Change**: `Data::load()` now takes one argument as the file name. 30 | - Use Workflow's cache directory for storing temp. files 31 | ### Added 32 | - Add a clear() method to Data struct. 33 | 34 | ## [0.4.3] - 2019-02-22 35 | ### Fixed 36 | - Fix crate version for docs.rs 37 | 38 | ## [0.4.0] - 2018-07-04 39 | ### Added 40 | - **Breaking changes** 41 | - Methods that save data now accept `ref` instead of moving the value to be save. 42 | ### Fixed 43 | - Checking for updates will now correctly make network calls after prior failures. 44 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "alfred-rs" 3 | version = "0.7.1" # update html_root_url & README.md 4 | authors = ["Hamid Ghadyani "] 5 | description = """ 6 | Write Alfred workflows easily. 7 | 8 | https://www.alfredapp.com 9 | """ 10 | 11 | edition = "2018" 12 | rust-version = "1.56" 13 | 14 | documentation = "https://docs.rs/alfred-rs/" 15 | homepage = "https://github.com/spamwax/alfred-workflow" 16 | repository = "https://github.com/spamwax/alfred-workflow" 17 | 18 | keywords = ["alfred", "workflow", "updater"] 19 | categories = ["development-tools"] 20 | 21 | readme = "README.md" 22 | license = "MIT/Apache-2.0" 23 | 24 | # [badges] 25 | # travis-ci = { repository = "spamwax/alfred-workflow" } 26 | 27 | [dependencies] 28 | serde = "1.0" 29 | serde_json = "1.0" 30 | serde_derive = "1.0" 31 | alfred = "4.0" 32 | anyhow = "1.0" 33 | log = "0.4" 34 | env_logger = "0.9" 35 | tempfile = "^3.0" 36 | 37 | chrono = { version = "0.4", features = ["serde"], optional = true } 38 | reqwest = { version = "0.11", features = ["blocking", "json"], optional = true} 39 | url = { version = "2.2", features = ["serde"], optional = true } 40 | semver = {version = "1.0", features = ["serde"], optional = true } 41 | 42 | [dev-dependencies] 43 | mockito = "0.31" 44 | 45 | [features] 46 | default = ["updater"] 47 | updater = ["chrono", "reqwest", "semver", "url"] 48 | 49 | [package.metadata.docs.rs] 50 | targets = ["x86_64-apple-darwin", "x86_64-apple-ios"] 51 | 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # alfred-rs 2 | 3 | [![alfred-rs](https://github.com/spamwax/alfred-workflow/actions/workflows/main.yml/badge.svg)](https://github.com/spamwax/alfred-workflow/actions/workflows/main.yml) 4 | [![alfred-rs](https://github.com/spamwax/alfred-workflow/actions/workflows/lint.yml/badge.svg)](https://github.com/spamwax/alfred-workflow/actions/workflows/lint.yml) 5 | [![crates.io/crates/alfred-rs](https://img.shields.io/crates/v/alfred-rs)](https://crates.io/crates/alfred-rs) 6 | [![https://docs.rs/alfred-rs/badge.svg](https://docs.rs/alfred-rs/badge.svg)](https://docs.rs/crate/alfred-rs/) 7 | 8 | Write [Workflows][] for [Alfred][alfred.app] app with ease! 9 | 10 | This crate adds enhanced features and quality-of-life improvements to 11 | [other alfred crate][alfred]'s basic functionality of generating items 12 | for **Script Filter** types in Alfred. 13 | 14 | Using this crate to create your workflows, you can 15 | - Set up automatic update of workflow ([`updater`] module). 16 | - Painlessly read/write data related to workflow (settings, cache data, ...) ([`data`] module). 17 | 18 | ## Documentation 19 | For examples and complete documentation visit [API Documentation][]. 20 | 21 | [`updater`]: https://docs.rs/alfred-rs/latest/alfred_rs/updater/index.html 22 | [`data`]: https://docs.rs/alfred-rs/latest/alfred_rs/data/index.html 23 | [alfred]: https://crates.io/crates/alfred 24 | [alfred.app]: http://www.alfredapp.com 25 | [Workflows]: https://www.alfredapp.com/workflows/ 26 | [API Documentation]: http://docs.rs/alfred-rs 27 | 28 | ## Changelog 29 | 30 | Change logs are now kept in a [separate document](./CHANGELOG.md). 31 | 32 | ## License 33 | 34 | Licensed under either of 35 | * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or 36 | http://www.apache.org/licenses/LICENSE-2.0) 37 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or 38 | http://opensource.org/licenses/MIT) at your option. 39 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # Based on the "trust" template v0.1.2 2 | # https://github.com/japaric/trust/tree/v0.1.2 3 | 4 | environment: 5 | global: 6 | # TODO This is the Rust channel that build jobs will use by default but can be 7 | # overridden on a case by case basis down below 8 | RUST_VERSION: stable 9 | 10 | # TODO Update this to match the name of your project. 11 | CRATE_NAME: alfred-rs 12 | 13 | # TODO These are all the build jobs. Adjust as necessary. Comment out what you 14 | # don't need 15 | matrix: 16 | # MSVC 17 | - TARGET: i686-pc-windows-msvc 18 | # - TARGET: x86_64-pc-windows-msvc 19 | 20 | install: 21 | - ps: >- 22 | If ($Env:TARGET -eq 'x86_64-pc-windows-gnu') { 23 | $Env:PATH += ';C:\msys64\mingw64\bin' 24 | } ElseIf ($Env:TARGET -eq 'i686-pc-windows-gnu') { 25 | $Env:PATH += ';C:\msys64\mingw32\bin' 26 | } 27 | - curl -sSf -o rustup-init.exe https://win.rustup.rs/ 28 | - rustup-init.exe -y --default-host %TARGET% --default-toolchain %RUST_VERSION% 29 | - set PATH=%PATH%;C:\Users\appveyor\.cargo\bin 30 | - rustc -Vv 31 | - cargo -V 32 | 33 | # TODO This is the "test phase", tweak it as you see fit 34 | test_script: 35 | # we don't run the "test phase" when doing deploys 36 | - if [%APPVEYOR_REPO_TAG%]==[false] ( 37 | set RUST_TEST_NOCAPTURE=1 && 38 | cargo build --target %TARGET% && 39 | cargo test --target %TARGET% --features updater --lib -- --test-threads=1 && 40 | cargo test --target %TARGET% --features updater --lib -- --test-threads=1 --ignored && 41 | cargo test --target %TARGET% --features updater --doc 42 | ) 43 | 44 | # cache: 45 | # - C:\Users\appveyor\.cargo\registry 46 | # - target 47 | 48 | branches: 49 | only: 50 | # Release tags 51 | - /^v?\d+\.\d+\.\d+.*$/ 52 | - master 53 | 54 | # notifications: 55 | # - provider: Email 56 | # on_build_success: false 57 | 58 | # # Building is done in the test phase, so we disable Appveyor's build phase. 59 | # build: false 60 | -------------------------------------------------------------------------------- /src/data/mod.rs: -------------------------------------------------------------------------------- 1 | //! Helper to store persistent or temporary data to disk. 2 | //! 3 | //! This module provides methods to store workflow related data 4 | //! (such as settings, configurations, ...) to disk. Additionally using the non-method functions 5 | //! workflow authors can save/load data to workflow's cache directory. 6 | //! 7 | //! To store/retrieve workflow related data, use [`set()`] and [`get()`] method after [`load()`]ing. 8 | //! Example of such data can be authentication info related to workflow or how many items 9 | //! you should show to user in Alfred's main window. 10 | //! 11 | //! To save/load temporary data, use [`save_to_file()`] and [`load_from_file()`] functions. 12 | //! Example of such data are cached list of items related to workflow or a downloaded file to be used later. 13 | //! 14 | //! # Example 15 | //! ```rust,no_run 16 | //! extern crate chrono; 17 | //! # use chrono::prelude::*; 18 | //! use alfred_rs::data::Data; 19 | //! 20 | //! // Load the workflow data (or create a new one) 21 | //! let mut workflow_data = Data::load("settings.json").unwrap(); 22 | //! 23 | //! // Set *and* save key/value `user_id: 0xFF` pair 24 | //! workflow_data.set("user_id", &0xFF); 25 | //! 26 | //! // We can set/save different data types. 27 | //! // For example, set and save timestamp of last use of workflow: 28 | //! workflow_data.set("last_use_date", &Utc::now()); 29 | //! 30 | //! 31 | //! // Later on, you can retreive the values: 32 | //! let last_use: DateTime = 33 | //! workflow_data.get("last_use_date").expect("timestamp was not set"); 34 | //! 35 | //! // Additioanlly, you can save temporary data to workflow's cache folder: 36 | //! Data::save_to_file("all_my_tweets.cache", &vec!["chirp1", "chirp2"]).unwrap(); 37 | //! ``` 38 | //! 39 | //! See `Data`'s [documentation] for more examples. 40 | //! 41 | //! [`load()`]: struct.Data.html#method.load 42 | //! [`set()`]: struct.Data.html#method.set 43 | //! [`get()`]: struct.Data.html#method.get 44 | //! [`save_to_file()`]: struct.Data.html#method.save_to_file 45 | //! [`load_from_file()`]: struct.Data.html#method.load_from_file 46 | //! [documentation]: struct.Data.html 47 | use super::{anyhow, bail, env, serde, serde_json, tempfile, Result}; 48 | 49 | use serde::Deserialize; 50 | use serde::Serialize; 51 | use serde_json::{from_value, to_value, Value}; 52 | use std::collections::HashMap; 53 | use std::fs::File; 54 | use std::io::{BufReader, BufWriter}; 55 | use std::path::{Path, PathBuf}; 56 | 57 | /// Workflow data that will be persisted to disk 58 | #[derive(Debug)] 59 | pub struct Data { 60 | inner: HashMap, 61 | file_name: PathBuf, 62 | } 63 | 64 | impl Data { 65 | /// Loads the workflow data or creates a new one. 66 | /// 67 | /// Reads the data stored in `p` file. 68 | /// Only file name section of `p` is used as data will be always saved 69 | /// in workflow's default data dir. 70 | /// If the file is missing or corrupt a new (empty) Data instance will be returned. 71 | /// 72 | /// # Errors 73 | /// This method can fail if any disk/IO error happens. 74 | pub fn load>(p: P) -> Result { 75 | if p.as_ref().as_os_str().is_empty() { 76 | bail!("File name to load data from cannot be empty"); 77 | } 78 | 79 | // Only use the file name section of input parameter. We will always save to Workflow's 80 | // data dir 81 | let filename = p 82 | .as_ref() 83 | .file_name() 84 | .ok_or_else(|| anyhow!("invalid file name"))?; 85 | let wf_data_path = env::workflow_data().ok_or_else(|| { 86 | anyhow!("missing env variable for data dir. forgot to set workflow bundle id?") 87 | })?; 88 | 89 | let wf_data_fn = wf_data_path.join(filename); 90 | 91 | let inner = Self::read_data_from_disk(&wf_data_fn) 92 | .or_else(|_| -> Result<_> { Ok(HashMap::new()) })?; 93 | Ok(Data { 94 | inner, 95 | file_name: wf_data_fn, 96 | }) 97 | } 98 | 99 | /// Set the value of key `k` to `v` and persist it to disk 100 | /// 101 | /// `k` is a type that implements `Into`. `v` can be any type as long as it 102 | /// implements `Serialize`. 103 | /// 104 | /// This method overwrites values of any existing keys, otherwise adds 105 | /// the key/value pair to the workflow's standard data file 106 | /// 107 | /// # Example 108 | /// ```rust,no_run 109 | /// # extern crate chrono; 110 | /// # use chrono::prelude::*; 111 | /// use alfred_rs::data::Data; 112 | /// 113 | /// let mut workflow_data = Data::load("settings.json").unwrap(); 114 | /// 115 | /// workflow_data.set("user_id", &0xFF); 116 | /// workflow_data.set("last_log_date", &Utc::now()); 117 | /// ``` 118 | /// # Errors 119 | /// 120 | /// If `v` cannot be serialized or there are file IO issues an error is returned. 121 | pub fn set(&mut self, k: K, v: &V) -> Result<()> 122 | where 123 | K: Into, 124 | V: Serialize, 125 | { 126 | let v = to_value(v)?; 127 | self.inner.insert(k.into(), v); 128 | Self::write_data_to_disk(&self.file_name, &self.inner) 129 | } 130 | 131 | /// Get (possible) value of key `k` from workflow's data 132 | /// 133 | /// If key `k` has not been set before `None` will be returned. 134 | /// 135 | /// Since the data can be of arbitrary type, you should annotate the type you are expecting 136 | /// to get back from data file. 137 | /// If the stored value cannot be deserialized back to the desired type `None` is returned. 138 | /// 139 | /// # Example 140 | /// ```rust,no_run 141 | /// # extern crate chrono; 142 | /// # use chrono::prelude::*; 143 | /// use alfred_rs::data::Data; 144 | /// 145 | /// let wf_data = Data::load("settings.json").unwrap(); 146 | /// 147 | /// let id: i32 = wf_data.get("user_id").expect("user id was not set"); 148 | /// let last_log: DateTime = wf_data.get("last_log_date").expect("log date was not set"); 149 | /// ``` 150 | pub fn get(&self, k: K) -> Option 151 | where 152 | K: AsRef, 153 | V: for<'d> Deserialize<'d>, 154 | { 155 | self.inner 156 | .get(k.as_ref()) 157 | .and_then(|v| from_value(v.clone()).ok()) 158 | } 159 | 160 | /// Clear all key-value pairs. Does not affect data on disk. 161 | pub fn clear(&mut self) { 162 | self.inner.clear(); 163 | } 164 | 165 | /// Function to save (temporary) `data` to file named `p` in workflow's cache dir 166 | /// 167 | /// This function is provided so that workflow authors can temporarily save information 168 | /// to workflow's cache dir. The saved data is considered to be irrelevant to workflow's 169 | /// actual data (for which you should use [`set`] and [`get`]) 170 | /// 171 | /// # Example 172 | /// ```rust,no_run 173 | /// use alfred_rs::data::Data; 174 | /// 175 | /// Data::save_to_file("cached_tags.dat", &vec!["rust", "alfred"]).unwrap(); 176 | /// ``` 177 | /// ## Note 178 | /// Only the [`file_name`] portion of `p` will be used to name the file that'll be stored in 179 | /// workflow's cache directory. 180 | /// # Errors 181 | /// File IO related issues as well as serializing problems will cause an error to be returned. 182 | /// 183 | /// [`set`]: struct.Data.html#method.set 184 | /// [`get`]: struct.Data.html#method.get 185 | /// [`file_name`]: https://doc.rust-lang.org/std/path/struct.Path.html#method.file_name 186 | pub fn save_to_file(p: P, data: &V) -> Result<()> 187 | where 188 | P: AsRef, 189 | V: Serialize, 190 | { 191 | let filename = p 192 | .as_ref() 193 | .file_name() 194 | .ok_or_else(|| anyhow!("invalid file name"))?; 195 | let p = env::workflow_cache() 196 | .map(|wfc| wfc.join(filename)) 197 | .ok_or_else(|| { 198 | anyhow!("missing env variable for cache dir. forgot to set workflow bundle id?") 199 | })?; 200 | debug!("saving to: {}", p.to_str().expect("")); 201 | Self::write_data_to_disk(p, data) 202 | } 203 | 204 | fn write_data_to_disk(p: P, data: &V) -> Result<()> 205 | where 206 | P: AsRef + std::fmt::Debug, 207 | V: Serialize, 208 | { 209 | use std::fs; 210 | use tempfile::Builder; 211 | let wfc = env::workflow_cache().ok_or_else(|| { 212 | anyhow!("missing env variable for cache dir. forgot to set workflow bundle id?") 213 | })?; 214 | let named_tempfile = Builder::new() 215 | .prefix("alfred_rs_temp") 216 | .suffix(".json") 217 | .rand_bytes(5) 218 | .tempfile_in(wfc)?; 219 | 220 | let fn_temp = named_tempfile.as_ref(); 221 | File::create(&fn_temp).and_then(|fp| { 222 | let buf_writer = BufWriter::with_capacity(0x1000, fp); 223 | serde_json::to_writer(buf_writer, data)?; 224 | Ok(()) 225 | })?; 226 | 227 | // Rename over to main file name 228 | fs::rename(fn_temp, p)?; 229 | Ok(()) 230 | } 231 | 232 | /// Function to load some (temporary) data from file named `p` in workflow's cache dir 233 | /// 234 | /// This function is provided so that workflow authors can retrieve temporarily information 235 | /// saved to workflow's cache dir. The saved data is considered to be irrelevant to workflow's 236 | /// actual data (for which you should use [`set`] and [`get`]) 237 | /// 238 | /// # Example 239 | /// 240 | /// ```rust,no_run 241 | /// use alfred_rs::data::Data; 242 | /// 243 | /// let cached_tags: Vec = Data::load_from_file("cached_tags.dat").unwrap(); 244 | /// ``` 245 | /// 246 | /// ## Note 247 | /// Only the [`file_name`] portion of `p` will be used to name the file, which will then be 248 | /// looked up in workflow's cache directory. 249 | /// 250 | /// [`set`]: struct.Data.html#method.set 251 | /// [`get`]: struct.Data.html#method.get 252 | /// [`file_name`]: https://doc.rust-lang.org/std/path/struct.Path.html#method.file_name 253 | pub fn load_from_file(p: P) -> Option 254 | where 255 | P: AsRef, 256 | V: for<'d> Deserialize<'d>, 257 | { 258 | let p = env::workflow_cache() 259 | .and_then(|wfc| p.as_ref().file_name().map(|name| wfc.join(name)))?; 260 | debug!("loading from: {}", p.to_str().expect("")); 261 | Self::read_data_from_disk(&p).ok() 262 | } 263 | 264 | fn read_data_from_disk(p: &Path) -> Result 265 | where 266 | V: for<'d> Deserialize<'d>, 267 | { 268 | File::open(p).map_err(Into::into).and_then(|fp| { 269 | let buf_reader = BufReader::with_capacity(0x1000, fp); 270 | let d: V = serde_json::from_reader(buf_reader)?; 271 | Ok(d) 272 | }) 273 | } 274 | } 275 | 276 | #[cfg(test)] 277 | mod tests { 278 | use super::*; 279 | use chrono::prelude::*; 280 | use std::env as StdEnv; 281 | use std::ffi::OsStr; 282 | use std::fs::remove_file; 283 | use std::{thread, time}; 284 | use tempfile::Builder; 285 | 286 | #[test] 287 | fn it_sets_gets_data() { 288 | #[derive(Serialize, Deserialize)] 289 | struct User { 290 | name: String, 291 | age: usize, 292 | } 293 | 294 | setup_workflow_env_vars(true); 295 | 296 | let user = User { 297 | name: "Hamid".to_string(), 298 | age: 42, 299 | }; 300 | 301 | { 302 | let mut wf_data: Data = Data::load("settings_test.json").unwrap(); 303 | wf_data.set("key1", &8).unwrap(); 304 | wf_data.set("key2", &user).unwrap(); 305 | wf_data.set("date", &Utc::now()).unwrap(); 306 | println!("{:?}", wf_data); 307 | } 308 | 309 | { 310 | let wf_data = Data::load("settings_test.json").unwrap(); 311 | 312 | assert_eq!(3, wf_data.inner.len()); 313 | let user: User = wf_data.get("key2").unwrap(); 314 | assert_eq!(42, user.age); 315 | 316 | let x: i8 = wf_data.get("key1").unwrap(); 317 | assert_eq!(8, x); 318 | let _last_log: DateTime = wf_data.get("date").expect("log date was not set"); 319 | } 320 | } 321 | 322 | #[test] 323 | fn it_saves_loads_from_file() { 324 | let wfc = setup_workflow_env_vars(true); 325 | let path = wfc.join("_test_saves_loads_from_file"); 326 | let _r = remove_file(&path); 327 | 328 | let now = Utc::now(); 329 | Data::save_to_file(&path, &now).expect("couldn't write to file"); 330 | let what_now: DateTime = 331 | Data::load_from_file(path).expect("couldn't get value from test file"); 332 | assert_eq!(now, what_now); 333 | } 334 | 335 | #[test] 336 | fn it_overwrites_cached_data_file() { 337 | let wfc = setup_workflow_env_vars(true); 338 | let path = wfc.join("_test_it_overwrites_cached_data_file"); 339 | let _r = remove_file(&path); 340 | 341 | let ten_millis = time::Duration::from_millis(10); 342 | 343 | let now1 = Utc::now(); 344 | Data::save_to_file(&path, &now1).expect("couldn't write to file"); 345 | 346 | thread::sleep(ten_millis); 347 | 348 | let now2 = Utc::now(); 349 | Data::save_to_file(&path, &now2).expect("couldn't write to file"); 350 | 351 | let what_now: DateTime = 352 | Data::load_from_file(path).expect("couldn't get value from test file"); 353 | assert_eq!(now2, what_now); 354 | } 355 | 356 | pub(super) fn setup_workflow_env_vars(secure_temp_dir: bool) -> PathBuf { 357 | // Mimic Alfred's environment variables 358 | let path = if secure_temp_dir { 359 | Builder::new() 360 | .prefix("alfred_workflow_test") 361 | .rand_bytes(5) 362 | .tempdir() 363 | .unwrap() 364 | .into_path() 365 | } else { 366 | StdEnv::temp_dir() 367 | }; 368 | { 369 | let v: &OsStr = path.as_ref(); 370 | StdEnv::set_var("alfred_workflow_data", v); 371 | StdEnv::set_var("alfred_workflow_cache", v); 372 | StdEnv::set_var("alfred_workflow_uid", "workflow.B0AC54EC-601C"); 373 | StdEnv::set_var( 374 | "alfred_workflow_name", 375 | "YouForgotTo/フ:Name好YouráOwnسWork}flowッ", 376 | ); 377 | StdEnv::set_var("alfred_workflow_bundleid", "MY_BUNDLE_ID"); 378 | StdEnv::set_var("alfred_workflow_version", "0.10.5"); 379 | } 380 | path 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Hamid R. Ghadyani. 2 | // Licensed under the Apache License, Version 2.0 or the MIT license 4 | // , at your 5 | // option. This file may not be copied, modified, or distributed 6 | // except according to those terms. 7 | 8 | //! Write [Workflows] for [Alfred][alfred.app] app with ease! 9 | //! 10 | //! This crate adds enhanced features and quality-of-life improvements to 11 | //! [other alfred crate][alfred]'s basic functionality of creating **Script Filter** items. 12 | //! 13 | //! Using this crate to create your workflows, you can 14 | //! - Set up automatic update of workflow ([`updater`] module). 15 | //! - Painlessly read/write data related to workflow (settings, cache data, ...) ([`data`] module). 16 | //! 17 | //! [`updater`]: updater/index.html 18 | //! [`data`]: data/index.html 19 | //! [alfred]: https://crates.io/crates/alfred 20 | //! [alfred.app]: http://www.alfredapp.com 21 | //! [Workflows]: https://www.alfredapp.com/workflows/ 22 | //! 23 | 24 | // TODO: check for "status" field of json returned by github to make sure it is fully uploaded 25 | // before reporting that a release is available. 26 | // TODO: Automatically update html_root_url's version when publishing to crates.io 27 | // TODO: Use https://github.com/softprops/hubcaps for github API? 28 | 29 | #![doc(html_root_url = "https://docs.rs/alfred-rs/0.7.1")] 30 | 31 | extern crate alfred; 32 | extern crate serde; 33 | extern crate serde_json; 34 | 35 | #[cfg(test)] 36 | extern crate mockito; 37 | 38 | #[macro_use] 39 | extern crate log; 40 | extern crate chrono; 41 | extern crate env_logger; 42 | extern crate semver; 43 | #[macro_use] 44 | extern crate serde_derive; 45 | extern crate tempfile; 46 | extern crate url; 47 | 48 | use alfred::env; 49 | use anyhow::Result; 50 | use anyhow::{anyhow, bail}; 51 | 52 | pub mod data; 53 | pub mod updater; 54 | 55 | pub use self::data::Data; 56 | pub use self::updater::Updater; 57 | -------------------------------------------------------------------------------- /src/updater/imp.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | anyhow, env, env_logger, remove_file, DateTime, PathBuf, Receiver, RefCell, Releaser, Result, 3 | Url, Utc, Version, UPDATE_INTERVAL, 4 | }; 5 | use crate::Updater; 6 | use std::cell::Cell; 7 | use std::cell::Ref; 8 | use std::cell::RefMut; 9 | use std::path::Path; 10 | use std::sync::mpsc; 11 | 12 | pub(super) const LATEST_UPDATE_INFO_CACHE_FN_ASYNC: &str = "last_check_status_async.json"; 13 | 14 | // Payload that the worker thread will send back 15 | type ReleasePayloadResult = Result>; 16 | 17 | #[derive(Debug, Serialize, Deserialize)] 18 | pub(super) struct UpdaterState { 19 | pub(super) last_check: Cell>>, 20 | 21 | current_version: Version, 22 | 23 | avail_release: RefCell>, 24 | 25 | #[serde(skip, default = "default_interval")] 26 | update_interval: i64, 27 | 28 | #[serde(skip)] 29 | worker_state: RefCell>, 30 | } 31 | 32 | impl UpdaterState { 33 | pub(super) fn current_version(&self) -> &Version { 34 | &self.current_version 35 | } 36 | 37 | pub(super) fn set_version(&mut self, v: Version) { 38 | self.current_version = v; 39 | } 40 | 41 | pub(super) fn latest_avail_version(&self) -> Option { 42 | self.avail_release 43 | .borrow() 44 | .as_ref() 45 | .map(|ui| ui.version().clone()) 46 | } 47 | 48 | pub(super) fn borrow_worker(&self) -> Ref<'_, Option> { 49 | self.worker_state.borrow() 50 | } 51 | 52 | pub(super) fn borrow_worker_mut(&self) -> RefMut<'_, Option> { 53 | self.worker_state.borrow_mut() 54 | } 55 | 56 | pub(super) fn download_url(&self) -> Option { 57 | self.avail_release 58 | .borrow() 59 | .as_ref() 60 | .map(|info| info.downloadable_url.clone()) 61 | } 62 | } 63 | 64 | #[derive(Debug, Serialize, Deserialize, Clone)] 65 | pub(super) struct UpdateInfo { 66 | // Latest version available from github or releaser 67 | pub version: Version, 68 | 69 | pub fetched_at: Option>, 70 | 71 | // Link to use to download the above version 72 | pub downloadable_url: Url, 73 | } 74 | 75 | impl UpdateInfo { 76 | pub fn new(v: Version, url: Url) -> Self { 77 | UpdateInfo { 78 | version: v, 79 | fetched_at: None, 80 | downloadable_url: url, 81 | } 82 | } 83 | 84 | pub(super) fn version(&self) -> &Version { 85 | &self.version 86 | } 87 | 88 | pub(super) fn fetched_at(&self) -> Option<&DateTime> { 89 | self.fetched_at.as_ref() 90 | } 91 | 92 | pub(super) fn set_fetched_at(&mut self, date_time: DateTime) { 93 | self.fetched_at = Some(date_time); 94 | } 95 | } 96 | 97 | #[derive(Debug)] 98 | pub(super) struct MPSCState { 99 | // First successful call on rx.recv() will cache the results into this field 100 | recvd_payload: RefCell>, 101 | // Receiver end of communication channel with worker thread 102 | rx: RefCell>>, 103 | } 104 | 105 | impl MPSCState { 106 | pub(super) fn new(rx: mpsc::Receiver) -> Self { 107 | MPSCState { 108 | recvd_payload: RefCell::new(None), 109 | rx: RefCell::new(Some(rx)), 110 | } 111 | } 112 | } 113 | 114 | impl Updater 115 | where 116 | T: Releaser + Send + 'static, 117 | { 118 | pub(super) fn load_or_new(r: T) -> Result { 119 | let _ = env_logger::try_init(); 120 | if let Ok(mut saved_state) = Self::load() { 121 | // Use the version that workflow reports through environment variable 122 | // This version takes priortiy over what we may have saved last time. 123 | let env_ver = env::workflow_version().and_then(|v| Version::parse(&v).ok()); 124 | if let Some(v) = env_ver { 125 | saved_state.current_version = v; 126 | } 127 | Ok(Updater { 128 | state: saved_state, 129 | releaser: RefCell::new(r), 130 | }) 131 | } else { 132 | let current_version = env::workflow_version() 133 | .map_or_else(|| Ok(Version::new(0, 0, 0)), |v| Version::parse(&v))?; 134 | let state = UpdaterState { 135 | current_version, 136 | last_check: Cell::new(None), 137 | avail_release: RefCell::new(None), 138 | worker_state: RefCell::new(None), 139 | update_interval: UPDATE_INTERVAL, 140 | }; 141 | let updater = Updater { 142 | state, 143 | releaser: RefCell::new(r), 144 | }; 145 | updater.save()?; 146 | Ok(updater) 147 | } 148 | } 149 | 150 | pub(super) fn last_check(&self) -> Option> { 151 | self.state.last_check.get() 152 | } 153 | 154 | pub(super) fn set_last_check(&self, t: DateTime) { 155 | self.state.last_check.set(Some(t)); 156 | } 157 | 158 | pub(super) fn update_interval(&self) -> i64 { 159 | self.state.update_interval 160 | } 161 | 162 | pub(super) fn set_update_interval(&mut self, t: i64) { 163 | self.state.update_interval = t; 164 | } 165 | 166 | fn load() -> Result { 167 | let data_file_path = Self::build_data_fn()?; 168 | crate::Data::load_from_file(data_file_path) 169 | .ok_or_else(|| anyhow!("cannot load cached state of updater")) 170 | } 171 | 172 | // Save updater's state 173 | pub(super) fn save(&self) -> Result<()> { 174 | let data_file_path = Self::build_data_fn()?; 175 | crate::Data::save_to_file(&data_file_path, &self.state).map_err(|e| { 176 | let _r = remove_file(data_file_path); 177 | e 178 | }) 179 | } 180 | 181 | pub(super) fn start_releaser_worker( 182 | &self, 183 | tx: mpsc::Sender, 184 | p: PathBuf, 185 | ) -> Result<()> { 186 | use std::thread; 187 | 188 | let releaser = (*self.releaser.borrow()).clone(); 189 | 190 | thread::Builder::new().spawn(move || { 191 | debug!("other thread: starting in updater thread"); 192 | let talk_to_mother = || -> Result<()> { 193 | let (v, url) = releaser.latest_release()?; 194 | let mut info = UpdateInfo::new(v, url); 195 | info.set_fetched_at(Utc::now()); 196 | let payload = Some(info); 197 | Self::write_last_check_status(&p, &payload)?; 198 | tx.send(Ok(payload))?; 199 | Ok(()) 200 | }; 201 | 202 | let outcome = talk_to_mother(); 203 | debug!("other thread: finished checking releaser status"); 204 | 205 | if let Err(error) = outcome { 206 | tx.send(Err(error)) 207 | .expect("could not send error from thread"); 208 | } 209 | })?; 210 | Ok(()) 211 | } 212 | 213 | // write version of latest avail. release (if any) to a cache file 214 | pub(super) fn write_last_check_status( 215 | p: &Path, 216 | updater_info: &Option, 217 | ) -> Result<()> { 218 | crate::Data::save_to_file(p, updater_info).map_err(|e| { 219 | let _r = remove_file(p); 220 | e 221 | }) 222 | } 223 | 224 | // read version of latest avail. release (if any) from a cache file 225 | pub(super) fn read_last_check_status(p: &Path) -> Result> { 226 | crate::Data::load_from_file(p).ok_or_else(|| anyhow!("no data in given path")) 227 | } 228 | 229 | pub(super) fn build_data_fn() -> Result { 230 | let workflow_name = env::workflow_name() 231 | .unwrap_or_else(|| "YouForgotTo/フ:NameYourOwnWork}flowッ".to_string()) 232 | .chars() 233 | .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) 234 | .collect::(); 235 | 236 | env::workflow_cache() 237 | .ok_or_else(|| { 238 | anyhow!("missing env variable for cache dir. forgot to set workflow bundle id?") 239 | }) 240 | .and_then(|mut data_path| { 241 | env::workflow_uid() 242 | .ok_or_else(|| anyhow!("missing env variable for uid")) 243 | .map(|ref uid| { 244 | let filename = [uid, "-", workflow_name.as_str(), "-updater.json"].concat(); 245 | data_path.push(filename); 246 | 247 | data_path 248 | }) 249 | }) 250 | } 251 | 252 | pub(super) fn update_ready_async(&self, try_flag: bool) -> Result { 253 | self.state 254 | .worker_state 255 | .borrow() 256 | .as_ref() 257 | .ok_or_else(|| anyhow!("you need to use init() method first.")) 258 | .and_then(|mpsc| { 259 | if mpsc.recvd_payload.borrow().is_none() { 260 | // No payload received yet, try to talk to worker thread 261 | mpsc.rx 262 | .borrow() 263 | .as_ref() 264 | .ok_or_else(|| anyhow!("you need to use init() correctly!")) 265 | .and_then(|rx| { 266 | let rr = if try_flag { 267 | // don't block while trying to receive 268 | rx.try_recv().map_err(|e| anyhow!(e.to_string())) 269 | } else { 270 | // block while waiting to receive 271 | rx.recv().map_err(|e| anyhow!(e.to_string())) 272 | }; 273 | rr.and_then(|msg| { 274 | let msg_status = msg.map(|update_info| { 275 | // received good message, update cache for received payload 276 | *self.state.avail_release.borrow_mut() = update_info.clone(); 277 | // update last_check if received info is newer than last_check 278 | update_info.as_ref().map(|ui| { 279 | ui.fetched_at().map(|fetched_time| { 280 | if self.last_check().is_none() 281 | || self.last_check().as_ref().unwrap() 282 | < fetched_time 283 | { 284 | self.set_last_check(*fetched_time); 285 | } 286 | }) 287 | }); 288 | *mpsc.recvd_payload.borrow_mut() = Some(Ok(update_info)); 289 | }); 290 | // save state regardless of content of msg 291 | self.save()?; 292 | msg_status?; 293 | Ok(()) 294 | }) 295 | })?; 296 | } 297 | Ok(()) 298 | })?; 299 | Ok(self 300 | .state 301 | .avail_release 302 | .borrow() 303 | .as_ref() 304 | .map_or(false, |release| *self.current_version() < release.version)) 305 | } 306 | 307 | #[allow(dead_code)] 308 | #[deprecated(note = "update_ready_async is deprecated. use init()")] 309 | pub(super) fn _update_ready_async(&self) -> Result { 310 | let worker_state = self.state.worker_state.borrow(); 311 | assert!(worker_state.is_some(), "you need to use init first"); 312 | 313 | let mpsc = worker_state.as_ref().expect("no worker_state"); 314 | if mpsc.recvd_payload.borrow().is_none() { 315 | let rx_option = mpsc.rx.borrow(); 316 | let rx = rx_option.as_ref().unwrap(); 317 | let rr = rx.recv(); 318 | if rr.is_ok() { 319 | let msg = rr.as_ref().unwrap(); 320 | if msg.is_ok() { 321 | let update_info = msg.as_ref().unwrap(); 322 | *self.state.avail_release.borrow_mut() = update_info.clone(); 323 | *mpsc.recvd_payload.borrow_mut() = Some(Ok(update_info.clone())); 324 | } else { 325 | return Err(anyhow!(format!("{:?}", msg.as_ref().unwrap_err()))); 326 | } 327 | self.save()?; 328 | } else { 329 | eprintln!("{:?}", rr); 330 | return Err(anyhow!(format!("{:?}", rr))); 331 | } 332 | } 333 | if let Some(ref updater_info) = *self.state.avail_release.borrow() { 334 | if *self.current_version() < updater_info.version { 335 | Ok(true) 336 | } else { 337 | Ok(false) 338 | } 339 | } else { 340 | Ok(false) 341 | } 342 | } 343 | 344 | #[allow(dead_code)] 345 | #[deprecated(note = "update_ready_sync is deprecated. use init()")] 346 | pub(super) fn _update_ready_sync(&self) -> Result { 347 | // A None value for last_check indicates that workflow is being run for first time. 348 | // Thus we update last_check to now and just save the updater state without asking 349 | // Releaser to do a remote call/check for us since we assume that user just downloaded 350 | // the workflow. 351 | const LATEST_UPDATE_INFO_CACHE_FN: &str = "last_check_status.json"; 352 | 353 | // file for status of last update check 354 | let p = Self::build_data_fn()?.with_file_name(LATEST_UPDATE_INFO_CACHE_FN); 355 | 356 | // make a network call to see if a newer version is avail. 357 | // save the result of call to cache file. 358 | let ask_releaser_for_update = || -> Result { 359 | let (v, url) = self.releaser.borrow().latest_release()?; 360 | let update_avail = *self.current_version() < v; 361 | 362 | let now = Utc::now(); 363 | let payload = { 364 | let mut info = UpdateInfo::new(v, url); 365 | info.set_fetched_at(now); 366 | Some(info) 367 | }; 368 | 369 | self.set_last_check(now); 370 | Self::write_last_check_status(&p, &payload)?; 371 | *self.state.avail_release.borrow_mut() = payload; 372 | 373 | self.save()?; 374 | Ok(update_avail) 375 | }; 376 | 377 | // if first time checking, just update the updater's timestamp, no network call 378 | if self.last_check().is_none() { 379 | self.set_last_check(Utc::now()); 380 | self.save()?; 381 | Ok(false) 382 | } else if self.due_to_check() { 383 | // it's time to talk to remote server 384 | ask_releaser_for_update() 385 | } else { 386 | Self::read_last_check_status(&p) 387 | .map(|last_check_status| { 388 | last_check_status.map_or(false, |last_update_info| { 389 | *self.current_version() < last_update_info.version 390 | }) 391 | // .unwrap_or(false) 392 | }) 393 | .or(Ok(false)) 394 | } 395 | } 396 | } 397 | 398 | pub(super) fn default_interval() -> i64 { 399 | UPDATE_INTERVAL 400 | } 401 | -------------------------------------------------------------------------------- /src/updater/mod.rs: -------------------------------------------------------------------------------- 1 | //! Helper for enabling Alfred workflows to upgrade themselves periodically (Alfred 3+) 2 | //! 3 | //! Using this module, the workflow author can make Alfred check for latest releases 4 | //! ([`try_update_ready()`] or [`update_ready()`]) from a remote server within adjustable intervals 5 | //! (default is 24 hrs). 6 | //! 7 | //! Additionally they can ask Alfred to download the new release to its cache folder for further 8 | //! action: [`download_latest()`]. 9 | //! 10 | //! For convenience, an associated method [`Updater::gh()`] is available to check 11 | //! for workflows hosted on `github.com`. 12 | //! 13 | //! However, it's possible to check with other servers as long as the [`Releaser`] trait is 14 | //! implemented for the desired remote service. 15 | //! See [`Updater::new()`] documentation if you are hosting your workflow 16 | //! on a non `github.com` service. 17 | //! 18 | //! ## Notes: 19 | //! - The `github.com` hosted repository should have release items following `github`'s process. 20 | //! This can be done by tagging a commit and then manually building a release where you 21 | //! attach/upload `YourWorkflow.alfredworkflow` to the release page. 22 | //! You can easily create `YourWorkflow.alfredworkflow` file by using the [export feature] of 23 | //! Alfred in its preferences window. 24 | //! 25 | //! - The tag should follow all of the [semantic versioning] rules. 26 | //! The only exception to those rules is that you can prepend your 27 | //! semantic version tag with ASCII letter `v`: `v0.3.1` or `0.3.1` 28 | //! 29 | //! # Note to workflow authors 30 | //! - Depending on network quality, checking if an update is available may take a long time. 31 | //! This module may spawn a worker thread so that the check does not block the main flow of your plugin. 32 | //! However given the limitations of Alfred's plugin architecture, the worker thread cannot outlive 33 | //! your plugin's executable. This means that you either have to wait/block for the worker thread, 34 | //! or if it is taking longer than a desirable time, you will have to abandon it. 35 | //! See the example for more details. 36 | //! - Workflow authors should make sure that _released_ workflow bundles have 37 | //! their version set in [Alfred's preferences window]. However, this module provides 38 | //! [`set_version()`] to set the version during runtime. 39 | //! 40 | //! [`Releaser`]: trait.Releaser.html 41 | //! [`Updater`]: struct.Updater.html 42 | //! [`update_ready()`]: struct.Updater.html#method.update_ready 43 | //! [`try_update_ready()`]: struct.Updater.html#method.try_update_ready 44 | //! [`download_latest()`]: struct.Updater.html#method.download_latest 45 | //! [`Updater::gh()`]: struct.Updater.html#method.gh 46 | //! [`Updater::new()`]: struct.Updater.html#method.new 47 | //! [semantic versioning]: https://semver.org 48 | //! [export feature]: https://www.alfredapp.com/help/workflows/advanced/sharing-workflows/ 49 | //! [Alfred's preferences window]: https://www.alfredapp.com/help/workflows/advanced/variables/ 50 | //! [`set_version()`]: struct.Updater.html#method.set_version 51 | //! [`set_interval()`]: struct.Updater.html#method.set_interval 52 | //! 53 | //! # Example 54 | //! 55 | //! Create an updater for a workflow hosted on `github.com/spamwax/alfred-pinboard-rs`. 56 | //! By default, it will check for new releases every 24 hours. 57 | //! To change the interval, use [`set_interval()`] method. 58 | //! 59 | //! ```rust,no_run 60 | //! # use anyhow::Result; 61 | //! extern crate alfred; 62 | //! 63 | //! // This crate 64 | //! extern crate alfred_rs; 65 | //! 66 | //! use alfred::{Item, ItemBuilder, json}; 67 | //! use alfred_rs::Updater; 68 | //! 69 | //! # use std::io; 70 | //! # fn produce_items_for_user_to_see<'a>() -> Vec> { 71 | //! # Vec::new() 72 | //! # } 73 | //! # fn do_some_other_stuff() {} 74 | //! // Our workflow's main 'runner' function 75 | //! fn run<'a>() -> Result>> { 76 | //! let updater = Updater::gh("spamwax/alfred-pinboard-rs")?; 77 | //! 78 | //! // Start the process for getting latest release info 79 | //! updater.init().expect("cannot initialize updater"); 80 | //! 81 | //! // We'll do some other work that's related to our workflow: 82 | //! do_some_other_stuff(); 83 | //! let mut items: Vec = produce_items_for_user_to_see(); 84 | //! 85 | //! // We can now check if update is ready using two methods: 86 | //! // 1- Block and wait until we receive results from worker thread 87 | //! // It's a good practice to only wait for worker for a limited time so 88 | //! // our workflow doesn't become unresponsive (not shown here) 89 | //! let update_status = updater.update_ready(); 90 | //! 91 | //! // 2- Or without blocking, check if the worker thread sent the results. 92 | //! // If the worker thread is still busy, we'll get an `Err` 93 | //! let update_status = updater.try_update_ready(); 94 | //! 95 | //! if let Ok(is_ready) = update_status { // Comm. with worker was successful 96 | //! // Check for new update and add an item to 'items' 97 | //! if is_ready { 98 | //! let update_item = ItemBuilder::new( 99 | //! "New version is available!" 100 | //! ).into_item(); 101 | //! // Add a new item to previous list of Items 102 | //! items.push(update_item); 103 | //! } 104 | //! } else { 105 | //! /* worker thread wasn't successful */ 106 | //! } 107 | //! Ok(items) 108 | //! } 109 | //! 110 | //! fn main() { 111 | //! // Fetch the items and show them. 112 | //! if let Ok(ref items) = run() { 113 | //! json::write_items(io::stdout(), items); 114 | //! } 115 | //! } 116 | //! ``` 117 | //! 118 | //! An *issue* with above example can be when user is on a poor network or server is unresponsive. 119 | //! In this case, the above snippet will try to call server every time workflow is invoked 120 | //! by Alfred until the operation succeeds. 121 | 122 | use super::{anyhow, bail, chrono, env_logger, semver, serde_json, url, Result}; 123 | use crate::env; 124 | use chrono::prelude::*; 125 | use chrono::Duration; 126 | use reqwest; 127 | use semver::Version; 128 | use std::cell::RefCell; 129 | use std::env as StdEnv; 130 | use std::fs::{remove_file, File}; 131 | use std::io::BufWriter; 132 | use std::path::PathBuf; 133 | use std::sync::mpsc::Receiver; 134 | use url::Url; 135 | mod imp; 136 | mod releaser; 137 | 138 | #[cfg(test)] 139 | mod tests; 140 | 141 | /// Default update check interval duration (24 hrs). To change the interval use the 142 | /// [`set_interval()`] method. 143 | /// 144 | /// [`set_interval()`]: struct.Updater.html#method.set_interval 145 | pub const UPDATE_INTERVAL: i64 = 24 * 60 * 60; 146 | 147 | pub use self::releaser::GithubReleaser; 148 | pub use self::releaser::Releaser; 149 | 150 | /// Struct to check for & download the latest release of workflow from a remote server. 151 | pub struct Updater 152 | where 153 | T: Releaser, 154 | { 155 | state: imp::UpdaterState, 156 | releaser: RefCell, 157 | } 158 | 159 | impl Updater { 160 | /// Create an `Updater` object that will interface with a `github` repository. 161 | /// 162 | /// The `repo_name` should be in `user_name/repository_name` form. See the 163 | /// [module level documentation](./index.html) for full example and description. 164 | /// 165 | /// ```rust 166 | /// # extern crate alfred_rs; 167 | /// use alfred_rs::Updater; 168 | /// 169 | /// # use std::env; 170 | /// # fn main() { 171 | /// # env::set_var("alfred_workflow_uid", "abcdef"); 172 | /// # env::set_var("alfred_workflow_data", env::temp_dir()); 173 | /// # env::set_var("alfred_workflow_cache", env::temp_dir()); 174 | /// # env::set_var("alfred_workflow_version", "0.0.0"); 175 | /// let updater = Updater::gh("spamwax/alfred-pinboard-rs").expect("cannot initiate Updater"); 176 | /// # } 177 | /// ``` 178 | /// 179 | /// This only creates an `Updater` without performing any network operations. 180 | /// To check availability of a new release, launch and check for updates by 181 | /// using [`init()`] and [`update_ready()`] or [`try_update_ready()`] methods. 182 | /// 183 | /// To download an available release use [`download_latest()`] method afterwards. 184 | /// 185 | /// # Errors 186 | /// Error will happen during calling this method if: 187 | /// - `Updater` state cannot be read/written during instantiation, or 188 | /// - The workflow version cannot be parsed as semantic version compatible identifier. 189 | /// 190 | /// [`init()`]: struct.Updater.html#method.init 191 | /// [`update_ready()`]: struct.Updater.html#method.update_ready 192 | /// [`try_update_ready()`]: struct.Updater.html#method.try_update_ready 193 | /// [`download_latest()`]: struct.Updater.html#method.download_latest 194 | pub fn gh(repo_name: S) -> Result 195 | where 196 | S: Into, 197 | { 198 | let releaser = GithubReleaser::new(repo_name); 199 | 200 | Self::load_or_new(releaser) 201 | } 202 | } 203 | 204 | impl Updater 205 | where 206 | T: Releaser + Send + 'static, 207 | { 208 | /// Create an `Updater` object that will interface with a remote repository for updating operations. 209 | /// 210 | /// `repo_name` is an arbitrary tag/identifier associated with the remote repository. 211 | /// 212 | /// How the `Updater` interacts with the remote server should be implemented using the [`Releaser`] 213 | /// trait. This crate provides a default implementation for interacting with 214 | /// `github.com` repositories, see [`gh()`] and [`GithubReleaser`]. 215 | /// 216 | /// # Example 217 | /// 218 | /// ```rust,no_run 219 | /// # extern crate alfred_rs; 220 | /// # extern crate semver; 221 | /// # use anyhow::Result; 222 | /// # extern crate url; 223 | /// 224 | /// use url::Url; 225 | /// use semver::Version; 226 | /// 227 | /// use alfred_rs::Updater; 228 | /// use alfred_rs::updater::Releaser; 229 | /// # use std::env; 230 | /// # fn main() { 231 | /// 232 | /// #[derive(Clone)] 233 | /// struct MyPrivateHost {/* inner */}; 234 | /// 235 | /// // You need to actually implement the trait, following is just a mock. 236 | /// impl Releaser for MyPrivateHost { 237 | /// type SemVersion = Version; 238 | /// type DownloadLink = Url; 239 | /// 240 | /// fn new>(project_id: S) -> Self { 241 | /// MyPrivateHost {} 242 | /// } 243 | /// 244 | /// fn fetch_latest_release(&self) -> Result<(Version, Url)> { 245 | /// let version = Version::new(1, 0, 12); 246 | /// let url = Url::parse("https://ci.remote.cc/release/latest")?; 247 | /// Ok((version, url)) 248 | /// } 249 | /// } 250 | /// 251 | /// let updater: Updater = 252 | /// Updater::new("my_hidden_proj").expect("cannot initiate Updater"); 253 | /// # } 254 | /// ``` 255 | /// 256 | /// This only creates an `Updater` without performing any network operations. 257 | /// To check availability of a new release, launch and check for updates by 258 | /// using [`init()`] and [`update_ready()`] or [`try_update_ready()`] methods. 259 | /// 260 | /// To check availability of a new release use [`update_ready()`] method. 261 | /// 262 | /// To download an available release use [`download_latest()`] method afterwards. 263 | /// 264 | /// # Errors 265 | /// Error will happen during calling this method if: 266 | /// - `Updater` state cannot be read/written during instantiation, or 267 | /// - The workflow version cannot be parsed as a semantic version compatible identifier. 268 | /// 269 | /// [`init()`]: struct.Updater.html#method.init 270 | /// [`update_ready()`]: struct.Updater.html#method.update_ready 271 | /// [`try_update_ready()`]: struct.Updater.html#method.try_update_ready 272 | /// [`download_latest()`]: struct.Updater.html#method.download_latest 273 | /// [`Releaser`]: trait.Releaser.html 274 | /// [`GithubReleaser`]: struct.GithubReleaser.html 275 | /// [`gh()`]: struct.Updater.html#method.gh 276 | pub fn new(repo_name: S) -> Result> 277 | where 278 | S: Into, 279 | { 280 | let releaser = Releaser::new(repo_name); 281 | Self::load_or_new(releaser) 282 | } 283 | 284 | /// Initializes `Updater` to fetch latest release information. 285 | /// 286 | /// - If it has been more than [`UPDATE_INTERVAL`] seconds (see [`set_interval()`]) since last check, 287 | /// the method will spawn a worker thread. 288 | /// In the background, the spawned thread will attempt to make a network call to fetch metadata of releases 289 | /// *only if* `UPDATE_INTERVAL` seconds has passed since the last network call. 290 | /// 291 | /// - All calls, which happen before the `UPDATE_INTERVAL` seconds, will initialize the `Updater` 292 | /// by using a local cache to report metadata about a release. 293 | /// 294 | /// For `Updater`s talking to `github.com`, the worker thread will only fetch a small 295 | /// metadata information to extract the version of the latest release. 296 | /// 297 | /// To check on status of worker thread and to get latest release status, use either of 298 | /// [`update_ready()`] or [`try_update_ready()`] methods. 299 | /// 300 | /// # Example 301 | /// 302 | /// ```rust,no_run 303 | /// # extern crate alfred_rs; 304 | /// # use anyhow::Result; 305 | /// # use alfred_rs::Updater; 306 | /// # use std::env; 307 | /// # fn do_some_other_stuff() {} 308 | /// # fn test_async() -> Result<()> { 309 | /// let updater = Updater::gh("spamwax/alfred-pinboard-rs")?; 310 | /// 311 | /// let rx = updater.init().expect("Error in starting updater."); 312 | /// 313 | /// // We'll do some other work that's related to our workflow while waiting 314 | /// do_some_other_stuff(); 315 | /// 316 | /// // We can now check if update is ready using two methods: 317 | /// // 1- Block and wait until we receive results or errors 318 | /// let update_status = updater.update_ready(); 319 | /// 320 | /// // 2- Or without blocking, check if the worker thread sent the results. 321 | /// // If the worker thread is still busy, we'll get an `Err` 322 | /// let update_status = updater.try_update_ready(); 323 | /// 324 | /// if let Ok(is_ready) = update_status { 325 | /// // Everything went ok: 326 | /// // No error happened during operation of worker thread 327 | /// // and we received release info 328 | /// if is_ready { 329 | /// // there is an update available. 330 | /// } 331 | /// } else { 332 | /// /* either the worker thread wasn't successful or we couldn't get its results */ 333 | /// } 334 | /// # Ok(()) 335 | /// # } 336 | /// # fn main() { 337 | /// # test_async(); 338 | /// # } 339 | /// ``` 340 | /// 341 | /// # Errors 342 | /// Followings can cause the method return an error: 343 | /// - A worker thread cannot be spawned 344 | /// - Alfred environment variable error 345 | /// - File IO error 346 | /// 347 | /// [`set_interval()`]: struct.Updater.html#method.set_interval 348 | /// [`update_ready()`]: struct.Updater.html#method.update_ready 349 | /// [`try_update_ready()`]: struct.Updater.html#method.try_update_ready 350 | /// [`UPDATE_INTERVAL`]: constant.UPDATE_INTERVAL.html 351 | #[allow(clippy::missing_panics_doc)] 352 | pub fn init(&self) -> Result<()> { 353 | use self::imp::LATEST_UPDATE_INFO_CACHE_FN_ASYNC; 354 | use std::sync::mpsc; 355 | let _ = env_logger::try_init(); 356 | 357 | debug!("entering init"); 358 | 359 | // file for status of last update check 360 | let p = Self::build_data_fn()?.with_file_name(LATEST_UPDATE_INFO_CACHE_FN_ASYNC); 361 | 362 | let (tx, rx) = mpsc::channel(); 363 | 364 | if self.last_check().is_none() { 365 | self.set_last_check(Utc::now()); 366 | self.save()?; 367 | // This send is always successful 368 | tx.send(Ok(None)).unwrap(); 369 | debug!(" last check was set to now()"); 370 | } else if self.due_to_check() { 371 | // it's time to talk to remote server 372 | debug!(" past UPDATE_INTERVAL, calling start_releaser_worker"); 373 | self.start_releaser_worker(tx, p)?; 374 | } else { 375 | debug!(" not past UPDATE_INTERVAL yet, calling read_last_check_status"); 376 | let status = Self::read_last_check_status(&p) 377 | .map(|last_check| { 378 | last_check.and_then(|info| { 379 | debug!(" read last_check_status: {:?}", info); 380 | if self.current_version() < info.version() { 381 | Some(info) 382 | } else { 383 | None 384 | } 385 | }) 386 | }) 387 | .or(Ok(None)); 388 | debug!(" status: {:?}", status); 389 | tx.send(status).unwrap(); 390 | } 391 | *self.state.borrow_worker_mut() = Some(imp::MPSCState::new(rx)); 392 | debug!("successfully set the state of release worker"); 393 | Ok(()) 394 | } 395 | 396 | /// Checks if a new update is available by waiting for the background thread to finish 397 | /// fetching release info (blocking). 398 | /// 399 | /// In practice, this method will block if it has been more than [`UPDATE_INTERVAL`] seconds 400 | /// since last check. In any other instance the updater will return the update status 401 | /// that was cached since last check. 402 | /// 403 | /// This method will wait for worker thread (spawned by calling [`init()`]) to deliver release 404 | /// information from remote server. 405 | /// Upon successful retrieval, this method will compare release information to the current 406 | /// vertion of the workflow. The remote repository should tag each release according to semantic 407 | /// version scheme for this to work. 408 | /// 409 | /// You should use this method after calling `init()`, preferably after your workflow is done with other tasks 410 | /// and now wants to get information about the latest release. 411 | /// 412 | /// # Note 413 | /// 414 | /// - Since this method may block the current thread until a response is received from remote server, 415 | /// workflow authors should consider scenarios where network connection is poor and the block can 416 | /// take a long time (>1 second), and devise their workflow around it. An alternative to 417 | /// this method is the non-blocking [`try_update_ready()`]. 418 | /// - The *very first* call to this method will always return false since it is assumed that 419 | /// user has just downloaded and installed the workflow. 420 | /// 421 | /// # Example 422 | /// 423 | /// ```no_run 424 | /// # extern crate alfred_rs; 425 | /// # use anyhow::Result; 426 | /// use alfred_rs::Updater; 427 | /// 428 | /// # use std::io; 429 | /// # fn main() { 430 | /// let updater = 431 | /// Updater::gh("spamwax/alfred-pinboard-rs").expect("cannot initiate Updater"); 432 | /// updater.init().expect("cannot start the worker thread"); 433 | /// 434 | /// // Perform other workflow related tasks... 435 | /// 436 | /// assert_eq!(true, updater.update_ready().expect("cannot get update information")); 437 | /// 438 | /// # } 439 | /// ``` 440 | /// 441 | /// # Errors 442 | /// Error will be returned : 443 | /// - If worker thread has been interrupted 444 | /// - If [`init()`] method has not been called successfully before this method 445 | /// - If worker could not communicate with server 446 | /// - If any file error or Alfred environment variable error happens 447 | /// 448 | /// [`init()`]: struct.Updater.html#method.init 449 | /// [`try_update_ready()`]: struct.Updater.html#method.try_update_ready 450 | /// [`UPDATE_INTERVAL`]: constant.UPDATE_INTERVAL.html 451 | pub fn update_ready(&self) -> Result { 452 | if self.state.borrow_worker().is_none() { 453 | bail!("update_ready_sync is deprecated. use init()"); 454 | } 455 | self.update_ready_async(false) 456 | } 457 | 458 | /// Try to get release info from background worker and see if a new update is available (non-blocking). 459 | /// 460 | /// This method will attempt to receive release information from worker thread 461 | /// (spawned by calling [`init()`]). Upon successful retrieval, this method will compare 462 | /// release information to the current vertion of the workflow. 463 | /// The remote repository should tag each release according to semantic version scheme 464 | /// for this to work. 465 | /// 466 | /// If communication with worker thread is not successful or if the worker thread could not 467 | /// fetch release information, this method will return an error. 468 | /// 469 | /// You should use this method after calling `init()`, preferably after your workflow is done with other tasks 470 | /// and now wants to get information about the latest release. 471 | /// 472 | /// # Note 473 | /// 474 | /// - To wait for the worker thread to deliver its release information you can use the blocking 475 | /// [`update_ready()`] method. 476 | /// - The *very first* call to this method will always return false since it is assumed that 477 | /// user has just downloaded and installed the workflow. 478 | /// 479 | /// # Example 480 | /// 481 | /// ```no_run 482 | /// extern crate alfred_rs; 483 | /// # use anyhow::Result; 484 | /// 485 | /// use alfred_rs::Updater; 486 | /// 487 | /// # use std::io; 488 | /// 489 | /// # fn do_some_other_stuff() {} 490 | /// 491 | /// fn main() { 492 | /// let updater = 493 | /// Updater::gh("spamwax/alfred-pinboard-rs").expect("cannot initiate Updater"); 494 | /// updater.init().expect("cannot start the worker thread"); 495 | /// 496 | /// // Perform other workflow related tasks... 497 | /// do_some_other_stuff(); 498 | /// 499 | /// assert_eq!(true, updater.try_update_ready().expect("cannot get update information")); 500 | /// 501 | /// // Execution of program will immediately follow to here since this method is non-blocking. 502 | /// 503 | /// } 504 | /// ``` 505 | /// 506 | /// # Errors 507 | /// Error will be returned : 508 | /// - If worker thread is not ready to send information or it has been interrupted 509 | /// - If [`init()`] method has not been called successfully before this method 510 | /// - If worker could not communicate with server 511 | /// - If any file error or Alfred environment variable error happens 512 | /// 513 | /// [`init()`]: struct.Updater.html#method.init 514 | /// [`update_ready()`]: struct.Updater.html#method.update_ready 515 | pub fn try_update_ready(&self) -> Result { 516 | if self.state.borrow_worker().is_none() { 517 | bail!("update_ready_sync is deprecated. use init()"); 518 | } 519 | self.update_ready_async(true) 520 | } 521 | 522 | /// Set workflow's version to `version`. 523 | /// 524 | /// Content of `version` needs to follow semantic versioning. 525 | /// 526 | /// This method is provided so workflow authors can set the version from within the Rust code. 527 | /// 528 | /// # Example 529 | /// 530 | /// ```rust 531 | /// # extern crate alfred_rs; 532 | /// # use anyhow::Result; 533 | /// # use alfred_rs::Updater; 534 | /// # use std::env; 535 | /// # fn ex_set_version() -> Result<()> { 536 | /// # env::set_var("alfred_workflow_uid", "abcdef"); 537 | /// # env::set_var("alfred_workflow_data", env::temp_dir()); 538 | /// # env::set_var("alfred_workflow_version", "0.0.0"); 539 | /// let mut updater = Updater::gh("spamwax/alfred-pinboard-rs")?; 540 | /// updater.set_version("0.23.3"); 541 | /// # Ok(()) 542 | /// # } 543 | /// 544 | /// # fn main() { 545 | /// # ex_set_version(); 546 | /// # } 547 | /// ``` 548 | /// An alternative (recommended) way of setting version is through [Alfred's preferences window]. 549 | /// 550 | /// [Alfred's preferences window]: https://www.alfredapp.com/help/workflows/advanced/variables/ 551 | /// 552 | /// # Panics 553 | /// The method will panic if the passed value `version` cannot be parsed as a semantic version compatible string. 554 | pub fn set_version>(&mut self, version: S) { 555 | let v = Version::parse(version.as_ref()) 556 | .expect("version should follow semantic version rules."); 557 | self.state.set_version(v); 558 | 559 | StdEnv::set_var("alfred_workflow_version", version.as_ref()); 560 | } 561 | 562 | /// Set the interval between checks for a newer release (in seconds) 563 | /// 564 | /// [Default value][`UPDATE_INTERVAL`] is 86,400 seconds (24 hrs). 565 | /// 566 | /// # Example 567 | /// Set interval to be 7 days 568 | /// 569 | /// ```rust 570 | /// # extern crate alfred_rs; 571 | /// # use alfred_rs::Updater; 572 | /// # use std::env; 573 | /// # fn main() { 574 | /// # env::set_var("alfred_workflow_uid", "abcdef"); 575 | /// # env::set_var("alfred_workflow_data", env::temp_dir()); 576 | /// # env::set_var("alfred_workflow_cache", env::temp_dir()); 577 | /// # env::set_var("alfred_workflow_version", "0.0.0"); 578 | /// let mut updater = 579 | /// Updater::gh("spamwax/alfred-pinboard-rs").expect("cannot initiate Updater"); 580 | /// updater.set_interval(7 * 24 * 60 * 60); 581 | /// # } 582 | /// ``` 583 | /// [`UPDATE_INTERVAL`]: constant.UPDATE_INTERVAL.html 584 | pub fn set_interval(&mut self, tick: i64) { 585 | self.set_update_interval(tick); 586 | } 587 | 588 | /// Check if it is time to ask remote server for latest updates. 589 | /// 590 | /// It returns `true` if it has been more than [`UPDATE_INTERVAL`] seconds since we last 591 | /// checked with server (i.e. ran [`update_ready()`]), otherwise returns false. 592 | /// 593 | /// [`update_ready()`]: struct.Updater.html#method.update_ready 594 | /// 595 | /// # Example 596 | /// 597 | /// ```rust,no_run 598 | /// # extern crate alfred_rs; 599 | /// # use anyhow::Result; 600 | /// # use alfred_rs::Updater; 601 | /// # fn run() -> Result<()> { 602 | /// let mut updater = Updater::gh("spamwax/alfred-pinboard-rs")?; 603 | /// 604 | /// // Assuming it is has been UPDATE_INTERVAL seconds since last time we ran the 605 | /// // `update_ready()` and there actually exists a new release: 606 | /// assert_eq!(true, updater.due_to_check()); 607 | /// # Ok(()) 608 | /// # } 609 | /// # fn main() { 610 | /// # run(); 611 | /// # } 612 | /// ``` 613 | /// 614 | /// [`UPDATE_INTERVAL`]: constant.UPDATE_INTERVAL.html 615 | pub fn due_to_check(&self) -> bool { 616 | self.last_check().map_or(true, |dt| { 617 | debug!("last check: {}", dt); 618 | Utc::now().signed_duration_since(dt) > Duration::seconds(self.update_interval()) 619 | }) 620 | } 621 | 622 | /// Method to download and save the latest release into workflow's cache dir. 623 | /// 624 | /// If the download and save operations are both successful, it returns name of file in which the 625 | /// downloaded Alfred workflow bundle is saved. 626 | /// 627 | /// The downloaded workflow will be saved in dedicated cache folder of the workflow, and it 628 | /// will be always renamed to `latest_release_WORKFLOW-NAME.alfredworkflow` 629 | /// 630 | /// To install the downloaded release, your workflow needs to somehow open the saved file. 631 | /// 632 | /// Within shell, it can be installed by issuing something like: 633 | /// ```bash 634 | /// open -b com.runningwithcrayons.Alfred latest_release_WORKFLOW-NAME.alfredworkflow 635 | /// ``` 636 | /// 637 | /// Or you can add "Run script" object to your workflow and use environment variables set by 638 | /// Alfred to automatically open the downloaded release: 639 | /// ```bash 640 | /// open -b com.runningwithcrayons.Alfred "$alfred_workflow_cache/latest_release_$alfred_workflow_name.alfredworkflow" 641 | /// ``` 642 | /// 643 | /// # Note 644 | /// 645 | /// The method may take longer than other Alfred-based actions to complete. Workflow authors using this crate 646 | /// should implement strategies to prevent unpleasant long blocks of user's typical work flow. 647 | /// 648 | /// One option to initiate the download and upgrade process is to invoke your executable with a 649 | /// different argument. The following snippet can be tied to a dedicated Alfred **Hotkey** 650 | /// or **Script Filter** so that it is only executed when user explicitly asks for it: 651 | /// 652 | /// # Example 653 | /// 654 | /// ```rust,no_run 655 | /// # extern crate alfred; 656 | /// # extern crate alfred_rs; 657 | /// # use anyhow::Result; 658 | /// # use std::io; 659 | /// use alfred_rs::Updater; 660 | /// use alfred::{ItemBuilder, json}; 661 | /// 662 | /// # fn main() { 663 | /// # let updater = 664 | /// # Updater::gh("spamwax/alfred-pinboard-rs").expect("cannot initiate Updater"); 665 | /// # let cmd_line_download_flag = true; 666 | /// if cmd_line_download_flag && updater.update_ready().unwrap() { 667 | /// match updater.download_latest() { 668 | /// Ok(downloaded_fn) => { 669 | /// json::write_items(io::stdout(), &[ 670 | /// ItemBuilder::new("New version of workflow is available!") 671 | /// .subtitle("Click to upgrade!") 672 | /// .arg(downloaded_fn.to_str().unwrap()) 673 | /// .variable("update_ready", "yes") 674 | /// .valid(true) 675 | /// .into_item() 676 | /// ]); 677 | /// }, 678 | /// Err(e) => { 679 | /// // Show an error message to user or log it. 680 | /// } 681 | /// } 682 | /// } 683 | /// # else { 684 | /// # } 685 | /// # } 686 | /// ``` 687 | /// 688 | /// For the above example to automatically work, you then need to connect the output of the script 689 | /// to an **Open File** action so that Alfred can install/upgrade the new version. 690 | /// 691 | /// As suggested in above example, you can add an Alfred variable to the item so that your workflow 692 | /// can use it for further processing. 693 | /// 694 | /// # Errors 695 | /// Downloading latest workflow can fail if network error, file error or Alfred environment variable 696 | /// errors happen, or if [`Releaser`] cannot produce a usable download url. 697 | /// 698 | /// [`Releaser`]: trait.Releaser.html 699 | pub fn download_latest(&self) -> Result { 700 | let url = self 701 | .state 702 | .download_url() 703 | .ok_or_else(|| anyhow!("no release info avail yet"))?; 704 | let client = reqwest::blocking::Client::new(); 705 | 706 | client 707 | .get(url) 708 | .send()? 709 | .error_for_status() 710 | .map_err(Into::into) 711 | .and_then(|mut resp| { 712 | // Get workflow's dedicated cache folder & build a filename 713 | let workflow_name = env::workflow_name() 714 | .unwrap_or_else(|| "WhyUNoNameYourOwnWorkflow".to_string()) 715 | .chars() 716 | .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) 717 | .collect::(); 718 | let latest_release_downloaded_fn = env::workflow_cache() 719 | .ok_or_else(|| { 720 | anyhow!( 721 | "missing env variable for cache dir. forgot to set workflow bundle id?", 722 | ) 723 | }) 724 | .map(|mut cache_dir| { 725 | cache_dir 726 | .push(["latest_release_", &workflow_name, ".alfredworkflow"].concat()); 727 | cache_dir 728 | })?; 729 | // Save the file 730 | File::create(&latest_release_downloaded_fn) 731 | .map_err(Into::into) 732 | .and_then(|fp| { 733 | let mut buf_writer = BufWriter::with_capacity(0x10_0000, fp); 734 | resp.copy_to(&mut buf_writer)?; 735 | Ok(()) 736 | }) 737 | .map_err(|e: anyhow::Error| { 738 | let _r = remove_file(&latest_release_downloaded_fn); 739 | e 740 | })?; 741 | Ok(latest_release_downloaded_fn) 742 | }) 743 | } 744 | 745 | /// Returns the version for the latest downloadable workflow from [`Releaser`]. 746 | /// `None` is returned if no release info has yet been fetched from server. 747 | /// 748 | /// # Note 749 | /// This method does not perform any network or disk IO. It merely returns the cached 750 | /// version info based on last successful communication with the remote server. 751 | /// So it is possible the method will return a version different than server's version if: 752 | /// - It's been less than [`UPDATE_INTERVAL`] seconds since last check, or 753 | /// - Worker thread is busy checking and you called this method before it finishes. 754 | /// 755 | /// [`Releaser`]: trait.Releaser.html 756 | /// [`UPDATE_INTERVAL`]: constant.UPDATE_INTERVAL.html 757 | /// [`update_ready()`]: struct.Updater.html#method.update_ready 758 | /// [`try_update_ready()`]: struct.Updater.html#method.try_update_ready 759 | pub fn latest_avail_version(&self) -> Option { 760 | self.state.latest_avail_version() 761 | } 762 | 763 | /// Get workflow's current version 764 | pub fn current_version(&self) -> &Version { 765 | self.state.current_version() 766 | } 767 | } 768 | -------------------------------------------------------------------------------- /src/updater/releaser.rs: -------------------------------------------------------------------------------- 1 | use super::{anyhow, reqwest, semver, serde_json, url, Result}; 2 | #[cfg(test)] 3 | use mockito; 4 | use semver::Version; 5 | use std::cell::RefCell; 6 | use url::Url; 7 | 8 | #[cfg(not(test))] 9 | const GITHUB_API_URL: &str = "https://api.github.com/repos/"; 10 | const GITHUB_LATEST_RELEASE_ENDPOINT: &str = "/releases/latest"; 11 | 12 | #[cfg(test)] 13 | #[allow(deprecated)] 14 | static MOCKITO_URL: &str = mockito::SERVER_URL; 15 | #[cfg(test)] 16 | pub const MOCK_RELEASER_REPO_NAME: &str = "MockZnVja29mZg==fd850fc2e63511e79f720023dfdf24ec"; 17 | 18 | /// An interface for checking with remote servers to identify the latest release for an 19 | /// Alfred workflow. 20 | /// 21 | /// This trait has been implemented for [`GithubReleaser`] to check for a newer version of a workflow 22 | /// that's maintained on `github.com` 23 | /// 24 | /// [`GithubReleaser`]: struct.GithubReleaser.html 25 | pub trait Releaser: Clone { 26 | /// Typte that represents semantic compatible identifier of a release. 27 | type SemVersion: Into; 28 | 29 | /// Type that represents a url to the latest release resource. 30 | type DownloadLink: Into; 31 | 32 | /// Creates a new `Releaser` instance that is identified as `name` 33 | fn new>(name: S) -> Self; 34 | 35 | /// Performs necessary communications to obtain release info in form of 36 | /// `SemVersion` and `DownloadLink` types. 37 | /// 38 | /// Returned tuple consists of semantic version compatible identifier of the release and 39 | /// a download link/url that can be used to fetch the release. 40 | /// 41 | /// Implementors are strongly encouraged to get the meta-data about the latest release without 42 | /// performing a full download of the workflow. 43 | /// 44 | /// # Errors 45 | /// Method returns `Err(Error)` on file or network error. 46 | fn fetch_latest_release(&self) -> Result<(Self::SemVersion, Self::DownloadLink)>; 47 | 48 | /// Returns the latest release information that is available from server. 49 | /// 50 | /// # Errors 51 | /// Method returns `Err(Error)` on file or network error. 52 | fn latest_release(&self) -> Result<(Version, Url)> { 53 | let (v, url) = self.fetch_latest_release()?; 54 | Ok((v.into(), url.into())) 55 | } 56 | } 57 | 58 | /// Struct to handle checking and finding release files from `github.com` 59 | /// 60 | /// This implementation of `Releaser` will favor files that end with `alfred3workflow` 61 | /// over `alfredworkflow`. If there are multiple `alfred3workflow`s or `alfredworkflow`s, the first 62 | /// one returned by `github.com` will be used. 63 | /// 64 | /// See [`updater::gh()`] for how to use this. 65 | /// 66 | /// [`updater::gh()`]: struct.Updater.html#method.gh 67 | #[allow(clippy::module_name_repetitions)] 68 | #[derive(Debug, Serialize, Deserialize, Clone)] 69 | pub struct GithubReleaser { 70 | repo: String, 71 | latest_release: RefCell>, 72 | } 73 | 74 | // Struct to store information about a single release point. 75 | // 76 | // Each release point may have multiple downloadable assets. 77 | #[derive(Debug, Serialize, Deserialize, Clone)] 78 | pub struct ReleaseItem { 79 | /// name of release that should hold a semver compatible identifier. 80 | pub tag_name: String, 81 | assets: Vec, 82 | } 83 | 84 | /// A single downloadable asset. 85 | #[derive(Debug, Serialize, Deserialize, Clone)] 86 | struct ReleaseAsset { 87 | url: String, 88 | name: String, 89 | state: String, 90 | browser_download_url: String, 91 | } 92 | 93 | impl GithubReleaser { 94 | fn latest_release_data(&self) -> Result<()> { 95 | debug!("starting latest_release_data"); 96 | let client = reqwest::blocking::Client::builder() 97 | .user_agent(concat!( 98 | env!("CARGO_PKG_NAME"), 99 | "/", 100 | env!("CARGO_PKG_VERSION") 101 | )) 102 | .build()?; 103 | 104 | #[cfg(test)] 105 | let url = format!("{}{}", MOCKITO_URL, GITHUB_LATEST_RELEASE_ENDPOINT); 106 | 107 | #[cfg(not(test))] 108 | let url = format!( 109 | "{}{}{}", 110 | GITHUB_API_URL, self.repo, GITHUB_LATEST_RELEASE_ENDPOINT 111 | ); 112 | debug!(" url is: {:?}", url); 113 | 114 | client 115 | .get(&url) 116 | .send()? 117 | .error_for_status() 118 | .map_err(Into::into) 119 | .and_then(|resp| { 120 | let mut latest: ReleaseItem = serde_json::from_reader(resp)?; 121 | if latest.tag_name.starts_with('v') { 122 | latest.tag_name.remove(0); 123 | } 124 | debug!(" release item: {:?}", latest); 125 | *self.latest_release.borrow_mut() = Some(latest); 126 | Ok(()) 127 | }) 128 | } 129 | 130 | // This implementation of Releaser will favor urls that end with `alfredworkflow` 131 | // over `alfredworkflow` 132 | fn downloadable_url(&self) -> Result { 133 | debug!("starting download_url"); 134 | self.latest_release 135 | .borrow() 136 | .as_ref() 137 | .ok_or_else(|| { 138 | anyhow!( 139 | "no release item available, did you first get version by calling latest_version?", 140 | ) 141 | }) 142 | .and_then(|r| { 143 | let urls = r 144 | .assets 145 | .iter() 146 | .filter(|asset| { 147 | asset.state == "uploaded" 148 | && (asset.browser_download_url.ends_with("alfredworkflow") 149 | || asset.browser_download_url.ends_with("alfred3workflow") 150 | || asset.browser_download_url.ends_with("alfred4workflow")) 151 | }) 152 | .map(|asset| &asset.browser_download_url) 153 | .collect::>(); 154 | debug!(" collected release urls: {:?}", urls); 155 | match urls.len() { 156 | 0 => Err(anyhow!("no usable download url")), 157 | 1 => Ok(Url::parse(urls[0])?), 158 | _ => { 159 | let url = urls.iter().find(|item| item.ends_with("alfredworkflow")); 160 | let u = url.unwrap_or(&urls[0]); 161 | Ok(Url::parse(u)?) 162 | } 163 | } 164 | }) 165 | } 166 | 167 | fn latest_version(&self) -> Result { 168 | debug!("starting latest_version"); 169 | if self.latest_release.borrow().is_none() { 170 | self.latest_release_data()?; 171 | } 172 | 173 | let latest_version = self 174 | .latest_release 175 | .borrow() 176 | .as_ref() 177 | .map(|r| Version::parse(&r.tag_name).ok()) 178 | .ok_or_else(|| anyhow!("Couldn't parse fetched version."))? 179 | .unwrap(); 180 | debug!(" latest version: {:?}", latest_version); 181 | Ok(latest_version) 182 | } 183 | } 184 | 185 | impl Releaser for GithubReleaser { 186 | type SemVersion = Version; 187 | type DownloadLink = Url; 188 | 189 | fn new>(repo_name: S) -> GithubReleaser { 190 | GithubReleaser { 191 | repo: repo_name.into(), 192 | latest_release: RefCell::new(None), 193 | } 194 | } 195 | 196 | fn fetch_latest_release(&self) -> Result<(Version, Url)> { 197 | if self.latest_release.borrow().is_none() { 198 | self.latest_release_data()?; 199 | } 200 | let version = self.latest_version()?; 201 | let link = self.downloadable_url()?; 202 | Ok((version, link)) 203 | } 204 | } 205 | 206 | #[cfg(test)] 207 | pub mod tests { 208 | use super::*; 209 | use mockito::{mock, Matcher, Mock}; 210 | 211 | #[test] 212 | fn it_tests_releaser() { 213 | let _m = setup_mock_server(200); 214 | let releaser = GithubReleaser::new(MOCK_RELEASER_REPO_NAME); 215 | 216 | // Calling downloadable_url before checking for latest_version will return error 217 | assert!(releaser.downloadable_url().is_err()); 218 | 219 | assert!( 220 | releaser 221 | .latest_version() 222 | .expect("couldn't do latest_version") 223 | > Version::new(0, 11, 0) 224 | ); 225 | 226 | assert_eq!("http://127.0.0.1:1234/releases/download/v0.11.1/alfred-pinboard-rust-v0.11.1.alfredworkflow", 227 | releaser.downloadable_url().unwrap().as_str()); 228 | } 229 | 230 | pub fn setup_mock_server(status_code: usize) -> Mock { 231 | mock( 232 | "GET", 233 | Matcher::Regex(r"^/releases/(latest|download).*$".to_string()), 234 | ) 235 | .with_status(status_code) 236 | .with_header("content-type", "application/json") 237 | .with_body(include_str!("../../tests/latest.json")) 238 | .create() 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/updater/tests.rs: -------------------------------------------------------------------------------- 1 | use self::releaser::tests::setup_mock_server; 2 | // #[cfg(not(feature = "ci"))] 3 | use self::releaser::GithubReleaser; 4 | use self::releaser::MOCK_RELEASER_REPO_NAME; 5 | use super::*; 6 | use std::ffi::OsStr; 7 | use std::{thread, time}; 8 | use tempfile::Builder; 9 | const VERSION_TEST: &str = "0.10.5"; 10 | const VERSION_TEST_NEW: &str = "0.11.1"; // should match what the mock server replies for new version. 11 | 12 | #[test] 13 | fn it_tests_settings_filename() { 14 | setup_workflow_env_vars(true); 15 | let updater_state_fn = Updater::::build_data_fn().unwrap(); 16 | assert_eq!( 17 | "workflow.B0AC54EC-601C-YouForgotTo___Name_Your_Own_Work_flow_-updater.json", 18 | updater_state_fn.file_name().unwrap().to_str().unwrap() 19 | ); 20 | } 21 | 22 | #[test] 23 | fn it_ignores_saved_version_after_an_upgrade_async() { 24 | // Make sure a freshly upgraded workflow does not use version info from saved state 25 | setup_workflow_env_vars(true); 26 | let _m = setup_mock_server(200); 27 | first_check_after_installing_workflow(); 28 | 29 | { 30 | // Next check it reports a new version since mock server has a release for us 31 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 32 | updater.set_interval(0); 33 | updater.init().expect("couldn't init worker"); 34 | 35 | assert!(updater.update_ready().expect("couldn't check for update")); 36 | assert_eq!(VERSION_TEST, format!("{}", updater.current_version())); 37 | } 38 | 39 | // Mimic the upgrade process by bumping the version 40 | StdEnv::set_var("alfred_workflow_version", VERSION_TEST_NEW); 41 | { 42 | let updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 43 | // Updater should pick up new version rather than using saved one 44 | assert_eq!(VERSION_TEST_NEW, format!("{}", updater.current_version())); 45 | updater.init().expect("couldn't init worker"); 46 | // No more updates 47 | assert!(!updater.update_ready().expect("couldn't check for update")); 48 | } 49 | } 50 | 51 | #[test] 52 | #[should_panic( 53 | expected = "HTTP status client error (400 Bad Request) for url (http://127.0.0.1:1234/releases/latest)" 54 | )] 55 | fn it_handles_server_error_async() { 56 | setup_workflow_env_vars(true); 57 | first_check_after_installing_workflow(); 58 | 59 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 60 | // Next check will be immediate 61 | updater.set_interval(0); 62 | updater.init().expect("couldn't init worker"); 63 | let _m = setup_mock_server(400); 64 | // This should panic with a BadRequest (400) error. 65 | updater.update_ready().unwrap(); 66 | } 67 | 68 | #[test] 69 | fn it_caches_async_workers_payload() { 70 | setup_workflow_env_vars(true); 71 | 72 | first_check_after_installing_workflow(); 73 | let _m = setup_mock_server(200); 74 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 75 | // Next check will be immediate 76 | updater.set_interval(0); 77 | updater.init().expect("couldn't init worker"); 78 | assert!(updater.update_ready().expect("couldn't check for update"),); 79 | 80 | // Consequent calls to update_ready should cache the payload. 81 | let _m = setup_mock_server(400); 82 | assert!(updater.update_ready().expect("couldn't check for update"),); 83 | assert!(updater.update_ready().expect("couldn't check for update"),); 84 | assert!(updater.update_ready().expect("couldn't check for update"),); 85 | 86 | { 87 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 88 | // Next check will be immediate 89 | updater.set_interval(0); 90 | updater.init().expect("couldn't init worker"); 91 | assert!(updater.update_ready().is_err()); 92 | } 93 | } 94 | 95 | #[test] 96 | fn it_get_latest_info_from_releaser() { 97 | setup_workflow_env_vars(true); 98 | let _m = setup_mock_server(200); 99 | 100 | { 101 | first_check_after_installing_workflow(); 102 | // Blocking 103 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 104 | // Next check will be immediate 105 | updater.set_interval(0); 106 | updater.init().expect("couldn't init worker"); 107 | 108 | assert!(updater 109 | .update_ready() 110 | .expect("Blocking: couldn't check for update")); 111 | } 112 | { 113 | // Non-blocking 114 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 115 | // Next check will be immediate 116 | updater.set_interval(0); 117 | // Start async worker 118 | updater.init().expect("couldn't init worker"); 119 | let wait = time::Duration::from_millis(500); 120 | thread::sleep(wait); 121 | 122 | assert!(updater 123 | .try_update_ready() 124 | .expect("Non-blocking: couldn't check for update")); 125 | } 126 | } 127 | 128 | #[allow(clippy::cast_possible_wrap)] 129 | #[test] 130 | fn it_does_one_network_call_per_interval() { 131 | { 132 | setup_workflow_env_vars(true); 133 | let _m = setup_mock_server(200); 134 | let wait_time = 1; 135 | 136 | first_check_after_installing_workflow(); 137 | 138 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 139 | // Next check will be immediate 140 | updater.set_interval(0); 141 | updater.init().expect("couldn't init worker"); 142 | 143 | // Next update_ready will make a network call 144 | assert!(updater.update_ready().expect("couldn't check for update")); 145 | 146 | // Increase interval 147 | updater.set_interval(wait_time as i64); 148 | assert!(!updater.due_to_check()); 149 | 150 | // make mock server return error. This way we can test that no network call was made 151 | // assuming Updater can read its cache file successfully 152 | let _m = setup_mock_server(503); 153 | let t = updater.update_ready(); 154 | assert!(t.is_ok()); 155 | // Make sure we still report update is ready 156 | assert!(t.unwrap()); 157 | 158 | // Now we test that after interval has passed we will make a call 159 | let two_sec = time::Duration::from_secs(wait_time); 160 | thread::sleep(two_sec); 161 | { 162 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 163 | updater.set_interval(wait_time as i64); 164 | updater.init().expect("couldn't init worker"); 165 | assert!(updater.due_to_check()); 166 | 167 | // Since server is returning error, update_ready() should fail. 168 | let t = updater.update_ready(); 169 | assert!(t.is_err()); 170 | } 171 | { 172 | // Just making sure the next call will go through and return expected results. 173 | let _m = setup_mock_server(200); 174 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 175 | // Next check will be immediate 176 | updater.set_interval(0); 177 | updater.init().expect("couldn't init worker"); 178 | assert!(updater.due_to_check()); 179 | 180 | // Since server is ok, update_ready() should work. 181 | let t = updater.update_ready(); 182 | assert!(t.is_ok()); 183 | assert!(updater.update_ready().expect("couldn't check for update")); 184 | } 185 | } 186 | } 187 | 188 | #[test] 189 | fn it_tests_download() { 190 | setup_workflow_env_vars(true); 191 | let _m = setup_mock_server(200); 192 | first_check_after_installing_workflow(); 193 | 194 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 195 | 196 | // Next check will be immediate 197 | updater.set_interval(0); 198 | // Force current version to be really old. 199 | updater.set_version("0.0.1"); 200 | updater.init().expect("couldn't init worker"); 201 | 202 | // New update is available 203 | assert!(updater.update_ready().expect("couldn't check for update")); 204 | 205 | let download_fn = updater.download_latest(); 206 | assert!(download_fn.is_ok()); 207 | assert_eq!( 208 | "latest_release_YouForgotTo___Name_Your_Own_Work_flow_.alfredworkflow", 209 | download_fn 210 | .unwrap() 211 | .file_name() 212 | .expect("couldn't get download file name") 213 | .to_str() 214 | .expect("impossible?!") 215 | ); 216 | } 217 | 218 | #[test] 219 | #[should_panic(expected = "no release info")] 220 | fn it_doesnt_download_without_release_info() { 221 | setup_workflow_env_vars(true); 222 | let _m = setup_mock_server(200); 223 | first_check_after_installing_workflow(); 224 | 225 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 226 | updater.set_interval(864_000); 227 | 228 | assert!(!updater.due_to_check()); 229 | updater.init().expect("couldn't init worker"); 230 | 231 | assert!(updater.download_latest().is_err()); 232 | 233 | // Since check time is due yet, following will just read cache without 234 | // getting any release info, hence the last line should panic 235 | assert!(!updater.update_ready().expect("couldn't check for update")); 236 | updater.download_latest().unwrap(); 237 | } 238 | 239 | #[test] 240 | fn it_downloads_after_getting_release_info() { 241 | setup_workflow_env_vars(true); 242 | let _m = setup_mock_server(200); 243 | first_check_after_installing_workflow(); 244 | 245 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 246 | updater.set_interval(0); 247 | updater.init().expect("couldn't init worker"); 248 | assert!(updater.download_latest().is_err()); 249 | 250 | assert!(updater.update_ready().expect("couldn't check for update")); 251 | assert!(updater.download_latest().is_ok()); 252 | } 253 | 254 | #[test] 255 | fn it_tests_async_updates_1() { 256 | // 257 | setup_workflow_env_vars(true); 258 | let _m = setup_mock_server(200); 259 | first_check_after_installing_workflow(); 260 | 261 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 262 | // Next check will be immediate 263 | updater.set_interval(0); 264 | updater.init().expect("couldn't init worker"); 265 | assert!(updater.update_ready().expect("couldn't check for update")); 266 | } 267 | 268 | #[test] 269 | fn it_tests_async_updates_2() { 270 | // This test will only spawn a thread once. 271 | // Second call will use a cache since it's not due to check. 272 | setup_workflow_env_vars(true); 273 | let _m = setup_mock_server(200); 274 | first_check_after_installing_workflow(); 275 | 276 | let mut updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 277 | 278 | // Next check will spawn a thread. There should be an update avail. from mock server. 279 | updater.set_interval(0); 280 | updater.init().expect("couldn't init worker"); 281 | updater.update_ready().expect("chouldn't check for update"); 282 | 283 | // make mock server return error. This way we can test that no network call was made 284 | // assuming Updater can read its cache file successfully 285 | let _m = setup_mock_server(503); 286 | // Increase interval 287 | updater.set_interval(86400); 288 | 289 | assert!(updater.update_ready().expect("couldn't check for update")); 290 | } 291 | 292 | pub(super) fn setup_workflow_env_vars(secure_temp_dir: bool) -> PathBuf { 293 | // Mimic Alfred's environment variables 294 | let path = if secure_temp_dir { 295 | Builder::new() 296 | .prefix("alfred_workflow_test") 297 | .rand_bytes(5) 298 | .tempdir() 299 | .unwrap() 300 | .into_path() 301 | } else { 302 | StdEnv::temp_dir() 303 | }; 304 | { 305 | let v: &OsStr = path.as_ref(); 306 | StdEnv::set_var("alfred_workflow_data", v); 307 | StdEnv::set_var("alfred_workflow_cache", v); 308 | StdEnv::set_var("alfred_workflow_uid", "workflow.B0AC54EC-601C"); 309 | StdEnv::set_var( 310 | "alfred_workflow_name", 311 | "YouForgotTo/フ:Name好YouráOwnسWork}flowッ", 312 | ); 313 | StdEnv::set_var("alfred_workflow_bundleid", "MY_BUNDLE_ID"); 314 | StdEnv::set_var("alfred_workflow_version", VERSION_TEST); 315 | } 316 | path 317 | } 318 | 319 | fn first_check_after_installing_workflow() { 320 | // since the first check after workflow installation by user will return no update available, 321 | // we need to run it at the beginning of some tests 322 | let _m = setup_mock_server(200); 323 | 324 | let updater = Updater::gh(MOCK_RELEASER_REPO_NAME).expect("cannot build Updater"); 325 | assert_eq!(VERSION_TEST, format!("{}", updater.current_version())); 326 | 327 | updater.init().expect("couldn't init worker"); 328 | 329 | // First update_ready is always false. 330 | assert!(!updater.update_ready().expect("couldn't check for update")); 331 | } 332 | -------------------------------------------------------------------------------- /tests/latest.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://api.github.com/repos/spamwax/alfred-pinboard-rs/releases/10548648", 3 | "assets_url": "https://api.github.com/repos/spamwax/alfred-pinboard-rs/releases/10548648/assets", 4 | "upload_url": "https://uploads.github.com/repos/spamwax/alfred-pinboard-rs/releases/10548648/assets{?name,label}", 5 | "html_url": "https://github.com/spamwax/alfred-pinboard-rs/releases/tag/v0.11.1", 6 | "id": 10548648, 7 | "tag_name": "v0.11.1", 8 | "target_commitish": "master", 9 | "name": null, 10 | "draft": false, 11 | "author": { 12 | "login": "spamwax", 13 | "id": 1251233, 14 | "avatar_url": "https://avatars0.githubusercontent.com/u/1251233?v=4", 15 | "gravatar_id": "", 16 | "url": "https://api.github.com/users/spamwax", 17 | "html_url": "https://github.com/spamwax", 18 | "followers_url": "https://api.github.com/users/spamwax/followers", 19 | "following_url": "https://api.github.com/users/spamwax/following{/other_user}", 20 | "gists_url": "https://api.github.com/users/spamwax/gists{/gist_id}", 21 | "starred_url": "https://api.github.com/users/spamwax/starred{/owner}{/repo}", 22 | "subscriptions_url": "https://api.github.com/users/spamwax/subscriptions", 23 | "organizations_url": "https://api.github.com/users/spamwax/orgs", 24 | "repos_url": "https://api.github.com/users/spamwax/repos", 25 | "events_url": "https://api.github.com/users/spamwax/events{/privacy}", 26 | "received_events_url": "https://api.github.com/users/spamwax/received_events", 27 | "type": "User", 28 | "site_admin": false 29 | }, 30 | "prerelease": false, 31 | "created_at": "2018-04-14T19:54:51Z", 32 | "published_at": "2018-04-14T19:57:26Z", 33 | "assets": [ 34 | { 35 | "url": "https://api.github.com/repos/spamwax/alfred-pinboard-rs/releases/assets/6847236", 36 | "id": 6847236, 37 | "name": "alfred-pinboard-rust-v0.11.1.alfredworkflow", 38 | "label": "", 39 | "uploader": { 40 | "login": "spamwax", 41 | "id": 1251233, 42 | "avatar_url": "https://avatars0.githubusercontent.com/u/1251233?v=4", 43 | "gravatar_id": "", 44 | "url": "https://api.github.com/users/spamwax", 45 | "html_url": "https://github.com/spamwax", 46 | "followers_url": "https://api.github.com/users/spamwax/followers", 47 | "following_url": "https://api.github.com/users/spamwax/following{/other_user}", 48 | "gists_url": "https://api.github.com/users/spamwax/gists{/gist_id}", 49 | "starred_url": "https://api.github.com/users/spamwax/starred{/owner}{/repo}", 50 | "subscriptions_url": "https://api.github.com/users/spamwax/subscriptions", 51 | "organizations_url": "https://api.github.com/users/spamwax/orgs", 52 | "repos_url": "https://api.github.com/users/spamwax/repos", 53 | "events_url": "https://api.github.com/users/spamwax/events{/privacy}", 54 | "received_events_url": "https://api.github.com/users/spamwax/received_events", 55 | "type": "User", 56 | "site_admin": false 57 | }, 58 | "content_type": "application/octet-stream", 59 | "state": "uploaded", 60 | "size": 2811073, 61 | "download_count": 1, 62 | "created_at": "2018-04-14T20:00:39Z", 63 | "updated_at": "2018-04-14T20:00:39Z", 64 | "browser_download_url": "http://127.0.0.1:1234/releases/download/v0.11.1/alfred-pinboard-rust-v0.11.1.alfredworkflow" 65 | }, 66 | { 67 | "url": "https://api.github.com/repos/spamwax/alfred-pinboard-rs/releases/assets/6847237", 68 | "id": 6847237, 69 | "name": "i686-apple-darwin-alfred-pinboard-rs-v0.11.1.tar.gz", 70 | "label": "", 71 | "uploader": { 72 | "login": "spamwax", 73 | "id": 1251233, 74 | "avatar_url": "https://avatars0.githubusercontent.com/u/1251233?v=4", 75 | "gravatar_id": "", 76 | "url": "https://api.github.com/users/spamwax", 77 | "html_url": "https://github.com/spamwax", 78 | "followers_url": "https://api.github.com/users/spamwax/followers", 79 | "following_url": "https://api.github.com/users/spamwax/following{/other_user}", 80 | "gists_url": "https://api.github.com/users/spamwax/gists{/gist_id}", 81 | "starred_url": "https://api.github.com/users/spamwax/starred{/owner}{/repo}", 82 | "subscriptions_url": "https://api.github.com/users/spamwax/subscriptions", 83 | "organizations_url": "https://api.github.com/users/spamwax/orgs", 84 | "repos_url": "https://api.github.com/users/spamwax/repos", 85 | "events_url": "https://api.github.com/users/spamwax/events{/privacy}", 86 | "received_events_url": "https://api.github.com/users/spamwax/received_events", 87 | "type": "User", 88 | "site_admin": false 89 | }, 90 | "content_type": "application/gzip", 91 | "state": "uploaded", 92 | "size": 2734175, 93 | "download_count": 0, 94 | "created_at": "2018-04-14T20:00:50Z", 95 | "updated_at": "2018-04-14T20:00:51Z", 96 | "browser_download_url": "https://github.com/spamwax/alfred-pinboard-rs/releases/download/v0.11.1/i686-apple-darwin-alfred-pinboard-rs-v0.11.1.tar.gz" 97 | } 98 | ], 99 | "tarball_url": "https://api.github.com/repos/spamwax/alfred-pinboard-rs/tarball/v0.11.1", 100 | "zipball_url": "https://api.github.com/repos/spamwax/alfred-pinboard-rs/zipball/v0.11.1", 101 | "body": null 102 | } 103 | --------------------------------------------------------------------------------