diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42eb6b4..07199b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,9 +74,53 @@ jobs: core.info(`OK: ${tagName} does not exist. Proceeding with release.`); core.setOutput('skip', 'false'); - publish-tauri: + generate-notes: + name: Generate release notes needs: preflight if: needs.preflight.outputs.skip != 'true' + permissions: + contents: read + runs-on: ubuntu-latest + outputs: + notes: ${{ steps.notes.outputs.notes }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Generate release notes via GitHub API + id: notes + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const cfg = JSON.parse(fs.readFileSync('src-tauri/tauri.conf.json', 'utf8')); + const version = cfg.version; + const tag = `v${version}`; + + const params = { + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + target_commitish: 'main', + }; + + // Use previous release as baseline if available + try { + const { data: latest } = await github.rest.repos.getLatestRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + params.previous_tag_name = latest.tag_name; + } catch (e) { + core.info('No previous release found, generating full notes'); + } + + const { data } = await github.rest.repos.generateReleaseNotes(params); + core.setOutput('notes', data.body); + + publish-tauri: + needs: [preflight, generate-notes] + if: needs.preflight.outputs.skip != 'true' permissions: contents: write strategy: @@ -129,10 +173,9 @@ jobs: with: tagName: v__VERSION__ releaseName: 'v__VERSION__' - releaseBody: 'See the assets to download this version and install.' + releaseBody: ${{ needs.generate-notes.outputs.notes }} releaseDraft: false prerelease: false - generateReleaseNotes: true includeUpdaterJson: true updaterJsonPreferNsis: true args: ${{ matrix.args }} diff --git a/package-lock.json b/package-lock.json index 55b1a97..30545f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,13 +22,16 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", + "@tauri-apps/plugin-updater": "^2.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.577.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", "shadcn": "^4.1.0", "shiki": "^4.0.2", "sonner": "^2.0.7", @@ -4638,6 +4641,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", + "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-store": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz", @@ -4647,6 +4659,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz", + "integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@tiptap/core": { "version": "3.20.4", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.4.tgz", @@ -4971,6 +4992,15 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -5517,6 +5547,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -6240,6 +6280,16 @@ "node": ">=4" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -6962,6 +7012,33 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-mdast": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/hast-util-to-mdast/-/hast-util-to-mdast-10.1.2.tgz", @@ -7049,6 +7126,16 @@ "node": ">=16.9.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -7158,6 +7245,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -7176,12 +7269,46 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -7227,6 +7354,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -8034,6 +8171,66 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", @@ -9131,6 +9328,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -9723,6 +9945,33 @@ "react": "*" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -10568,6 +10817,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", diff --git a/package.json b/package.json index 7eefe33..a521852 100644 --- a/package.json +++ b/package.json @@ -28,13 +28,16 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", + "@tauri-apps/plugin-updater": "^2.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.577.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", "shadcn": "^4.1.0", "shiki": "^4.0.2", "sonner": "^2.0.7", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7e96b92..06a41bd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3585,6 +3585,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", + "tauri-plugin-process", "tauri-plugin-store", "tauri-plugin-updater", "uuid", @@ -4366,6 +4367,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-store" version = "2.4.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 694edb5..767c755 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,4 +25,5 @@ chrono = { version = "0.4", features = ["serde"] } reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } scraper = "0.23" tauri-plugin-fs = "2" +tauri-plugin-process = "2" tauri-plugin-updater = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 909a36d..dc6ab86 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,7 @@ "dialog:default", "fs:default", "updater:default", + "process:allow-restart", { "identifier": "fs:allow-write-file", "allow": [{ "path": "$APPDATA/images" }, { "path": "$APPDATA/images/**" }] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3cbdb2c..cbe92a7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,8 @@ use tauri::Manager; /// - **store** — Persistent key-value config storage. /// - **dialog** — Native file/message dialogs. /// - **fs** — Scoped filesystem access. +/// - **updater** — In-app update checking and installation. +/// - **process** — Application process management (relaunch after update). /// /// During setup the SQLite database is initialized, a /// [`LinkPreviewCache`](link_preview::LinkPreviewCache) is registered as @@ -42,6 +44,7 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_updater::Builder::default().build()) + .plugin(tauri_plugin_process::init()) .setup(|app| { db::init_db(app.handle())?; app.manage(link_preview::LinkPreviewCache(Mutex::new(HashMap::new()))); diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 12edac0..5881e09 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -76,7 +76,8 @@ pub fn create_main_window(app: &AppHandle) -> Result<(), Box(null) // True once the persisted lastNoteId has been loaded from the store. @@ -511,6 +514,7 @@ function AppContent() { + ) } @@ -526,13 +530,17 @@ function AppContent() { * @returns The rendered application tree. */ function App() { + const { config: configStore } = useAppStore() + return ( - + + + diff --git a/src/app/providers/update-provider.tsx b/src/app/providers/update-provider.tsx new file mode 100644 index 0000000..acac132 --- /dev/null +++ b/src/app/providers/update-provider.tsx @@ -0,0 +1,241 @@ +/** + * React Context provider for the app-update state machine. + * + * Wires the pure {@link updateReducer} to Tauri plugin APIs and + * configStore persistence. Holds the non-serializable `Update` + * object in a ref and exposes typed actions to consumers. + * + * @module app/providers/update-provider + */ + +import type { LazyStore } from '@tauri-apps/plugin-store' +import { + createContext, + type ReactNode, + useCallback, + useContext, + useReducer, + useRef, + useState, +} from 'react' +import { + checkForAppUpdate, + relaunchApp, + type Update, +} from '@/features/app-update/api' +import { SKIPPED_VERSION_STORE_KEY } from '@/features/app-update/lib/updateConfig' +import { + INITIAL_STATE, + type UpdateState, + updateReducer, +} from '@/features/app-update/lib/updateReducer' +import { splashDonePromise } from '@/features/splash' + +/** + * Shape of the value exposed by the update context. + * + * Consumers obtain this via the {@link useAppUpdate} hook. + */ +interface UpdateContextValue { + /** Current state of the update state machine. */ + state: UpdateState + /** Whether the update dialog is currently visible. */ + dialogOpen: boolean + /** + * Initiates an update check. + * + * @param options.manual - When `true`, shows the dialog immediately and + * reports "up to date" / errors to the user. When `false` (default), + * runs silently and only surfaces the dialog if an update is available. + */ + checkForUpdate: (options?: { manual?: boolean }) => Promise + /** + * Downloads and installs the available update, then relaunches the app. + * No-op if no update is available. + */ + startUpdate: () => Promise + /** + * Persists the currently available version as "skipped" so it is + * suppressed in future automatic checks, then dismisses the dialog. + */ + skipVersion: () => void + /** Closes the dialog and resets the state to idle (unless non-dismissable). */ + dismiss: () => void +} + +const UpdateContext = createContext(null) + +/** + * Provides app-update state and actions to the component tree. + * + * @param props.configStore - The LazyStore instance for persisting + * the skipped version preference. + */ +export function UpdateProvider({ + children, + configStore, +}: { + children: ReactNode + configStore: LazyStore +}) { + const [state, dispatch] = useReducer(updateReducer, INITIAL_STATE) + const [dialogOpen, setDialogOpen] = useState(false) + const updateRef = useRef(null) + const skippedVersionRef = useRef(null) + const skippedVersionLoadedRef = useRef(false) + + const loadSkippedVersion = useCallback(async () => { + if (skippedVersionLoadedRef.current) return + skippedVersionLoadedRef.current = true + try { + const v = await configStore.get(SKIPPED_VERSION_STORE_KEY) + skippedVersionRef.current = v ?? null + } catch (err) { + console.error('Failed to load skippedUpdateVersion:', err) + skippedVersionLoadedRef.current = false + } + }, [configStore]) + + const checkForUpdate = useCallback( + async (options?: { manual?: boolean }) => { + const manual = options?.manual ?? false + + await loadSkippedVersion() + + if (manual) { + setDialogOpen(true) + } + dispatch({ type: 'CHECK_START' }) + + try { + const update = await checkForAppUpdate() + + if (update) { + // Auto-check: suppress if version was skipped + if (!manual && update.version === skippedVersionRef.current) { + dispatch({ type: 'DISMISS' }) + return + } + + updateRef.current = update + dispatch({ + type: 'UPDATE_AVAILABLE', + version: update.version, + body: update.body ?? '', + date: update.date ?? '', + }) + if (!manual) { + await splashDonePromise + } + setDialogOpen(true) + } else { + if (manual) { + dispatch({ type: 'UP_TO_DATE' }) + } else { + // Auto-check: no feedback needed + dispatch({ type: 'DISMISS' }) + } + } + } catch (err) { + const message = + err instanceof Error ? err.message : 'Unknown error occurred' + if (manual) { + dispatch({ type: 'ERROR', message }) + } else { + // Auto-check: fail silently + console.warn('Startup update check failed:', message) + dispatch({ type: 'DISMISS' }) + } + } + }, + [loadSkippedVersion] + ) + + const startUpdate = useCallback(async () => { + const update = updateRef.current + if (!update || state.status !== 'available') return + + dispatch({ type: 'DOWNLOAD_START' }) + + let downloaded = 0 + let total = 0 + + try { + await update.downloadAndInstall((event) => { + switch (event.event) { + case 'Started': + total = event.data.contentLength ?? 0 + break + case 'Progress': + downloaded += event.data.chunkLength + dispatch({ type: 'DOWNLOAD_PROGRESS', downloaded, total }) + break + case 'Finished': + dispatch({ type: 'INSTALL_START' }) + break + } + }) + + dispatch({ type: 'RESTART' }) + await relaunchApp() + } catch (err) { + const message = err instanceof Error ? err.message : 'Download failed' + dispatch({ type: 'ERROR', message }) + } + }, [state.status]) + + const skipVersion = useCallback(() => { + if (state.status !== 'available') return + const { version } = state + + skippedVersionRef.current = version + configStore.set(SKIPPED_VERSION_STORE_KEY, version).catch((err) => { + console.error('Failed to persist skippedUpdateVersion:', err) + }) + + dispatch({ type: 'DISMISS' }) + setDialogOpen(false) + }, [state, configStore]) + + const dismiss = useCallback(() => { + const nonDismissable = new Set([ + 'downloading', + 'installing', + 'restarting', + 'checking', + ]) + if (nonDismissable.has(state.status)) return + + dispatch({ type: 'DISMISS' }) + setDialogOpen(false) + }, [state.status]) + + return ( + + {children} + + ) +} + +/** + * Returns the update state and actions from the nearest + * {@link UpdateProvider}. + * + * @throws {Error} If used outside of an ``. + */ +export function useAppUpdate() { + const ctx = useContext(UpdateContext) + if (!ctx) { + throw new Error('useAppUpdate must be used within an UpdateProvider') + } + return ctx +} diff --git a/src/features/app-update/api/index.ts b/src/features/app-update/api/index.ts new file mode 100644 index 0000000..9d8a4b0 --- /dev/null +++ b/src/features/app-update/api/index.ts @@ -0,0 +1,54 @@ +/** + * Thin wrappers around Tauri updater and process plugins. + * + * Isolates native API calls for mockability and keeps plugin + * imports in one place. + * + * @module features/app-update/api + */ + +import { relaunch } from '@tauri-apps/plugin-process' +import { check } from '@tauri-apps/plugin-updater' + +export type { Update } from '@tauri-apps/plugin-updater' + +/** Maximum time (ms) to wait for the updater API before aborting. */ +const CHECK_TIMEOUT_MS = 10_000 + +/** + * Checks for available application updates with a timeout guard. + * + * Wraps the Tauri updater `check()` call in a `Promise.race` against + * a timeout so the caller is never blocked indefinitely by a slow or + * unreachable update server. + * + * @returns The `Update` object describing the available release, or + * `null` when the app is already up to date. + * @throws {Error} If the check times out or the network request fails. + */ +export async function checkForAppUpdate() { + let timeoutId: ReturnType | undefined + try { + const result = await Promise.race([ + check(), + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error('Update check timed out')), + CHECK_TIMEOUT_MS + ) + }), + ]) + return result + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId) + } +} + +/** + * Restarts the application after an update has been installed. + * + * Delegates to the Tauri process plugin's `relaunch()` command. + */ +export async function relaunchApp() { + await relaunch() +} diff --git a/src/features/app-update/hooks/index.ts b/src/features/app-update/hooks/index.ts new file mode 100644 index 0000000..28c1142 --- /dev/null +++ b/src/features/app-update/hooks/index.ts @@ -0,0 +1,31 @@ +/** + * Triggers an automatic update check on mount. + * + * Runs a non-blocking background network request immediately. + * Suppresses UI feedback when no update is found. + * + * @module features/app-update/hooks + */ + +import { useEffect } from 'react' +import { useAppUpdate } from '@/app/providers/update-provider' + +/** + * Fires a non-blocking update check when the component mounts. + * + * Intended to be called once from the root application component so + * that available updates are detected at startup. The check runs in + * "auto" mode (`manual: false`), which means: + * - No UI feedback is shown when the app is already up to date. + * - A skipped version is silently suppressed. + * - Network errors are logged but not surfaced to the user. + */ +export function useUpdateCheckOnLaunch() { + const { checkForUpdate } = useAppUpdate() + + useEffect(() => { + checkForUpdate({ manual: false }).catch((err) => { + console.error('Startup update check failed:', err) + }) + }, [checkForUpdate]) +} diff --git a/src/features/app-update/index.ts b/src/features/app-update/index.ts new file mode 100644 index 0000000..5b32647 --- /dev/null +++ b/src/features/app-update/index.ts @@ -0,0 +1,14 @@ +/** + * App-update feature public API. + * + * @module features/app-update + */ + +export { useUpdateCheckOnLaunch } from './hooks' +export { SKIPPED_VERSION_STORE_KEY } from './lib/updateConfig' +export type { + UpdateAction, + UpdateState, + UpdateStatus, +} from './lib/updateReducer' +export { UpdateDialog } from './ui/UpdateDialog' diff --git a/src/features/app-update/lib/updateConfig.ts b/src/features/app-update/lib/updateConfig.ts new file mode 100644 index 0000000..56239f2 --- /dev/null +++ b/src/features/app-update/lib/updateConfig.ts @@ -0,0 +1,2 @@ +/** Store key for the version string the user chose to skip. */ +export const SKIPPED_VERSION_STORE_KEY = 'skippedUpdateVersion' as const diff --git a/src/features/app-update/lib/updateReducer.ts b/src/features/app-update/lib/updateReducer.ts new file mode 100644 index 0000000..2da6890 --- /dev/null +++ b/src/features/app-update/lib/updateReducer.ts @@ -0,0 +1,168 @@ +/** + * Pure reducer and type definitions for the app-update state machine. + * + * The lifecycle is linear with a few branches: + * idle → checking → available | upToDate | error + * available → downloading → installing → restarting + * + * @module features/app-update/lib/updateReducer + */ + +/** + * Discriminated union of all possible update lifecycle phases. + * + * Used as the `status` field in {@link UpdateState} to drive the UI + * and guard state transitions in the reducer. + */ +export type UpdateStatus = + | 'idle' + | 'checking' + | 'available' + | 'upToDate' + | 'downloading' + | 'installing' + | 'restarting' + | 'error' + +/** + * Discriminated union representing the complete update state. + * + * Each variant carries only the data relevant to its phase—e.g. + * `available` includes `version`, `body`, and `date`, while + * `downloading` tracks byte progress. + */ +export type UpdateState = + | { status: 'idle' } + | { status: 'checking' } + | { status: 'available'; version: string; body: string; date: string } + | { status: 'upToDate' } + | { + status: 'downloading' + version: string + body: string + downloaded: number + total: number + } + | { status: 'installing'; version: string } + | { status: 'restarting' } + | { status: 'error'; message: string } + +/** + * Union of all actions the update reducer can process. + * + * Each action triggers a guarded state transition—dispatching an + * action from an unexpected state is a no-op (the reducer returns + * the current state unchanged). + */ +export type UpdateAction = + | { type: 'CHECK_START' } + | { type: 'UPDATE_AVAILABLE'; version: string; body: string; date: string } + | { type: 'UP_TO_DATE' } + | { type: 'DOWNLOAD_START' } + | { type: 'DOWNLOAD_PROGRESS'; downloaded: number; total: number } + | { type: 'INSTALL_START' } + | { type: 'RESTART' } + | { type: 'ERROR'; message: string } + | { type: 'DISMISS' } + +/** Starting state for the update state machine (no check in progress). */ +export const INITIAL_STATE: UpdateState = { status: 'idle' } + +/** + * Pure reducer that drives the app-update state machine. + * + * All transitions are guarded: an action dispatched from an + * unexpected state returns the current state unchanged, making + * the reducer safe to call at any time. + * + * @param state - The current update state. + * @param action - The action describing the requested transition. + * @returns The next update state. + */ +export function updateReducer( + state: UpdateState, + action: UpdateAction +): UpdateState { + switch (action.type) { + case 'CHECK_START': + if (state.status === 'idle' || state.status === 'error') { + return { status: 'checking' } + } + return state + + case 'UPDATE_AVAILABLE': + if (state.status === 'checking') { + return { + status: 'available', + version: action.version, + body: action.body, + date: action.date, + } + } + return state + + case 'UP_TO_DATE': + if (state.status === 'checking') { + return { status: 'upToDate' } + } + return state + + case 'DOWNLOAD_START': + if (state.status === 'available') { + return { + status: 'downloading', + version: state.version, + body: state.body, + downloaded: 0, + total: 0, + } + } + return state + + case 'DOWNLOAD_PROGRESS': + if (state.status === 'downloading') { + return { + ...state, + downloaded: action.downloaded, + total: action.total, + } + } + return state + + case 'INSTALL_START': + if (state.status === 'downloading') { + return { status: 'installing', version: state.version } + } + return state + + case 'RESTART': + if (state.status === 'installing') { + return { status: 'restarting' } + } + return state + + case 'ERROR': + if ( + state.status === 'checking' || + state.status === 'downloading' || + state.status === 'installing' + ) { + return { status: 'error', message: action.message } + } + return state + + case 'DISMISS': + if ( + state.status === 'checking' || + state.status === 'available' || + state.status === 'upToDate' || + state.status === 'error' + ) { + return { status: 'idle' } + } + return state + + default: + return state + } +} diff --git a/src/features/app-update/ui/UpdateDialog.tsx b/src/features/app-update/ui/UpdateDialog.tsx new file mode 100644 index 0000000..c066e3e --- /dev/null +++ b/src/features/app-update/ui/UpdateDialog.tsx @@ -0,0 +1,309 @@ +/** + * Modal dialog that renders different content based on the current + * update state machine status. + * + * Non-dismissable during download/install/restart to prevent the + * user from accidentally interrupting the process. + * + * @module features/app-update/ui/UpdateDialog + */ + +import { getVersion } from '@tauri-apps/api/app' +import { openUrl } from '@tauri-apps/plugin-opener' +import { + AlertTriangle, + CheckCircle2, + Download, + ExternalLink, + Loader2, + RefreshCw, +} from 'lucide-react' +import { useEffect, useState } from 'react' +import Markdown from 'react-markdown' +import { useAppUpdate } from '@/app/providers/update-provider' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' + +const RELEASES_URL = 'https://github.com/j4rviscmd/Scripta/releases' + +/** + * Top-level update dialog that renders the appropriate sub-view + * based on the current update state machine status. + * + * The dialog is non-dismissable during `downloading`, `installing`, + * `restarting`, and `checking` phases to prevent accidental interruption. + */ +export function UpdateDialog() { + const { state, dialogOpen, dismiss, startUpdate, skipVersion } = + useAppUpdate() + + const canDismiss = ![ + 'downloading', + 'installing', + 'restarting', + 'checking', + ].includes(state.status) + + const handleOpenChange = (open: boolean) => { + if (!open && canDismiss) dismiss() + } + + return ( + + + {state.status === 'checking' && } + {state.status === 'available' && ( + + )} + {state.status === 'upToDate' && } + {state.status === 'downloading' && ( + + )} + {state.status === 'installing' && ( + + )} + {state.status === 'restarting' && } + {state.status === 'error' && } + + + ) +} + +/** Spinner view shown while the update check network request is in flight. */ +function CheckingView() { + return ( + +
+ + Checking for Updates +
+ Please wait… +
+ ) +} + +/** + * View displayed when a new version is available. + * + * Shows the version number, the current installed version, parsed + * release notes (Markdown), and action buttons to update now, skip + * this version, or defer. + */ +function AvailableView({ + version, + body, + onUpdate, + onLater, + onSkip, +}: { + version: string + body: string + onUpdate: () => void + onLater: () => void + onSkip: () => void +}) { + const [currentVersion, setCurrentVersion] = useState(null) + + useEffect(() => { + getVersion() + .then(setCurrentVersion) + .catch(() => {}) + }, []) + + return ( + <> + + Update Available + + A new version v{version} is + ready to install. + {currentVersion && ( + + Currently installed: v{currentVersion} + + )} + + + {body && + body.includes('## ') && + (() => { + const notes = body.slice(body.indexOf('## ')) + return ( +
+ {notes} +
+ ) + })()} + + + + + + + + ) +} + +/** + * Progress bar view shown while the update binary is being downloaded. + * + * Renders a determinate progress bar when `total` is known, or an + * indeterminate pulsing bar otherwise. + */ +function DownloadingView({ + downloaded, + total, +}: { + downloaded: number + total: number +}) { + const percentage = total > 0 ? Math.round((downloaded / total) * 100) : 0 + const isIndeterminate = total === 0 + + return ( + <> + + Downloading Update + + {isIndeterminate ? 'Downloading…' : `Downloading… ${percentage}%`} + + +
+ {isIndeterminate ? ( +
+
+
+ ) : ( +
+
+
+ )} +
+ + ) +} + +/** Spinner view shown while the downloaded update is being installed. */ +function InstallingView({ version }: { version: string }) { + return ( + +
+ + Installing v{version} +
+ Please wait… +
+ ) +} + +/** View shown briefly while the application is relaunching after installation. */ +function RestartingView() { + return ( + +
+ + Restarting +
+ + The application will restart shortly. + +
+ ) +} + +/** Confirmation view shown when the app is already running the latest version. */ +function UpToDateView() { + const { dismiss } = useAppUpdate() + const [currentVersion, setCurrentVersion] = useState(null) + + useEffect(() => { + getVersion() + .then(setCurrentVersion) + .catch(() => {}) + }, []) + + return ( + <> + +
+ + You're Up to Date +
+ + Scripta is running the latest version. + {currentVersion && ` (v${currentVersion})`} + +
+ + + + + ) +} + +/** Error view with the failure message and a "Try Again" button. */ +function ErrorView({ message }: { message: string }) { + const { checkForUpdate, dismiss } = useAppUpdate() + + return ( + <> + +
+ + Update Error +
+ {message} +
+ + + + + + ) +} diff --git a/src/features/settings/ui/CheckForUpdatesOption.tsx b/src/features/settings/ui/CheckForUpdatesOption.tsx new file mode 100644 index 0000000..fd1ccf5 --- /dev/null +++ b/src/features/settings/ui/CheckForUpdatesOption.tsx @@ -0,0 +1,54 @@ +/** + * Settings section with a "Check for Updates" button. + * + * Reads the update state from {@link useAppUpdate} to disable + * the button and show a spinner while a check is in progress. + * + * @module features/settings/ui/CheckForUpdatesOption + */ + +import { openUrl } from '@tauri-apps/plugin-opener' +import { ExternalLink, RefreshCw } from 'lucide-react' +import { useAppUpdate } from '@/app/providers/update-provider' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +/** GitHub releases page URL shown as an external link below the button. */ +const RELEASES_URL = 'https://github.com/j4rviscmd/Scripta/releases' + +/** + * Settings section that provides a manual "Check for Updates" button. + * + * The button is disabled and shows a spinner while a check is in + * progress. A link to the GitHub releases page is displayed below. + */ +export function CheckForUpdatesOption() { + const { state, checkForUpdate } = useAppUpdate() + const isChecking = state.status === 'checking' + + return ( +
+

Updates

+
+ + +
+
+ ) +} diff --git a/src/features/settings/ui/SettingsDialog.tsx b/src/features/settings/ui/SettingsDialog.tsx index ae57d1a..98cf5ca 100644 --- a/src/features/settings/ui/SettingsDialog.tsx +++ b/src/features/settings/ui/SettingsDialog.tsx @@ -8,6 +8,7 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Separator } from '@/components/ui/separator' +import { CheckForUpdatesOption } from './CheckForUpdatesOption' import { CommandPaletteScrollOption } from './CommandPaletteScrollOption' import { CursorAutoHideOption } from './CursorAutoHideOption' import { CursorCenteringOption } from './CursorCenteringOption' @@ -48,6 +49,7 @@ const themeOptions: Array<{ value: Theme; label: string; icon: LucideIcon }> = [ * - **Editor font** — Editor font family selection (Google Fonts). * - **Toolbar** — Formatting toolbar item order and visibility via {@link ToolbarOption}. * - **Window state** — Restore window position and size on launch. + * - **Updates** — Manual update check via {@link CheckForUpdatesOption}. * * The dialog is controlled externally through the `open` and * `onOpenChange` props. @@ -89,6 +91,8 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { + + ) diff --git a/src/features/splash/hooks/useSplashLifecycle.ts b/src/features/splash/hooks/useSplashLifecycle.ts index 642ebb0..cf6ef46 100644 --- a/src/features/splash/hooks/useSplashLifecycle.ts +++ b/src/features/splash/hooks/useSplashLifecycle.ts @@ -17,7 +17,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window' import { useCallback, useEffect, useRef, useState } from 'react' import { storeInitPromise } from '@/app/providers/store-provider' import { FADE_DURATION_MS, MIN_DISPLAY_MS } from '../lib/constants' -import { notifySplashFading } from '../lib/splash-state' +import { notifySplashDone, notifySplashFading } from '../lib/splash-state' /** Phases of the splash screen lifecycle. */ type SplashPhase = 'active' | 'fading' | 'done' @@ -67,6 +67,7 @@ export function useSplashLifecycle(): SplashLifecycle { // Re-enable window operations once the splash is fully dismissed. useEffect(() => { if (phase !== 'done') return + notifySplashDone() const appWindow = getCurrentWindow() Promise.all([ appWindow.setResizable(true), diff --git a/src/features/splash/index.ts b/src/features/splash/index.ts index ef525f8..4270043 100644 --- a/src/features/splash/index.ts +++ b/src/features/splash/index.ts @@ -8,5 +8,5 @@ * time has elapsed, then fades out and unmounts. */ -export { splashFadingPromise } from './lib/splash-state' +export { splashDonePromise, splashFadingPromise } from './lib/splash-state' export { SplashScreen } from './ui/SplashScreen' diff --git a/src/features/splash/lib/splash-state.ts b/src/features/splash/lib/splash-state.ts index e56ba23..4198c02 100644 --- a/src/features/splash/lib/splash-state.ts +++ b/src/features/splash/lib/splash-state.ts @@ -1,4 +1,5 @@ let resolveFading: (() => void) | null = null +let resolveDone: (() => void) | null = null /** * Promise that resolves when the splash screen begins its fade-out transition. @@ -10,7 +11,20 @@ export const splashFadingPromise = new Promise((resolve) => { resolveFading = resolve }) +/** + * Promise that resolves when the splash screen has fully completed + * (fade-out transition finished and component unmounted). + */ +export const splashDonePromise = new Promise((resolve) => { + resolveDone = resolve +}) + /** Called by the splash lifecycle when the `fading` phase begins. */ export function notifySplashFading() { resolveFading?.() } + +/** Called by the splash lifecycle when the `done` phase is reached. */ +export function notifySplashDone() { + resolveDone?.() +}