diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..21d540e1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + name: Build & Release (macOS) + permissions: + contents: write + strategy: + matrix: + include: + - target: aarch64-apple-darwin + runner: macos-latest + - target: x86_64-apple-darwin + runner: macos-latest + + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build and release + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + tagName: ${{ github.ref_name }} + releaseName: 'Laputa v__VERSION__' + releaseBody: 'See the assets below to download and install this version.' + releaseDraft: false + prerelease: false + args: --target ${{ matrix.target }} diff --git a/package.json b/package.json index 60451401..26a95fd5 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "katex": "^0.16.28", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 949346a2..15428e3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,12 @@ importers: '@tauri-apps/api': specifier: ^2.10.1 version: 2.10.1 + '@tauri-apps/plugin-process': + specifier: ^2.3.1 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2.10.0 + version: 2.10.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1708,6 +1714,12 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-updater@2.10.0': + resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -5075,6 +5087,14 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.10.1 + + '@tauri-apps/plugin-updater@2.10.0': + dependencies: + '@tauri-apps/api': 2.10.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d4464009..254e0497 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -93,6 +93,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -633,6 +642,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -884,6 +904,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1911,6 +1942,8 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-log", + "tauri-plugin-process", + "tauri-plugin-updater", "tempfile", "tokio", "walkdir", @@ -1976,6 +2009,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.11.0", "libc", + "redox_syscall 0.7.1", ] [[package]] @@ -2066,6 +2100,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2374,6 +2414,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2481,6 +2533,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2524,7 +2590,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2967,6 +3033,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -3104,15 +3179,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3212,12 +3292,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3227,6 +3320,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.9" @@ -3630,7 +3750,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -3850,6 +3970,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4010,6 +4141,49 @@ dependencies = [ "time", ] +[[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-updater" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.10.0" @@ -4887,6 +5061,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5631,6 +5814,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yaml-rust2" version = "0.8.1" @@ -5745,6 +5938,18 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8e9a7826..0e4641e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,8 @@ futures-util = "0.3" base64 = "0.22" regex = "1" dirs = "5" +tauri-plugin-updater = "2.10.0" +tauri-plugin-process = "2.3.1" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c135d7f1..5e95bf9c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,8 @@ "main" ], "permissions": [ - "core:default" + "core:default", + "updater:default", + "process:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3d590606..140995f0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -90,6 +90,12 @@ pub fn run() { )?; } + #[cfg(desktop)] + { + app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; + app.handle().plugin(tauri_plugin_process::init())?; + } + // Purge trashed files older than 30 days on startup let vault_path = dirs::home_dir() .map(|h| h.join("Laputa")) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 04e74f15..0a22c0a5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -33,6 +33,7 @@ "bundle": { "active": true, "targets": "all", + "createUpdaterArtifacts": true, "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -40,5 +41,13 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "updater": { + "endpoints": [ + "https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json" + ], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERFNzVEOTA0Q0U2QUE5ODIKUldTQ3FXck9CTmwxM2x2eUZyOFYwUno0ZzVXVStCeG9xcUcvK0pIWnRWS0xMbmEwVE84NUdXejAK" + } } } diff --git a/src/App.tsx b/src/App.tsx index ad30a740..2545c495 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { useAppKeyboard } from './hooks/useAppKeyboard' import { useEntryActions } from './hooks/useEntryActions' import { isTauri } from './mock-tauri' import { useKeyboardNavigation } from './hooks/useKeyboardNavigation' +import { useUpdater } from './hooks/useUpdater' import type { SidebarSelection, GitCommit } from './types' import './App.css' @@ -110,6 +111,8 @@ function App() { handleCloseTabRef: notes.handleCloseTabRef, }) + useUpdater() + useKeyboardNavigation({ tabs: notes.tabs, activeTabPath: notes.activeTabPath, diff --git a/src/hooks/useUpdater.test.ts b/src/hooks/useUpdater.test.ts new file mode 100644 index 00000000..d63d83df --- /dev/null +++ b/src/hooks/useUpdater.test.ts @@ -0,0 +1,107 @@ +import { renderHook } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { useUpdater } from './useUpdater' + +// Mock isTauri +vi.mock('../mock-tauri', () => ({ + isTauri: vi.fn(() => false), +})) + +// Mock the dynamic imports +const mockCheck = vi.fn() +const mockRelaunch = vi.fn() + +vi.mock('@tauri-apps/plugin-updater', () => ({ + check: (...args: unknown[]) => mockCheck(...args), +})) + +vi.mock('@tauri-apps/plugin-process', () => ({ + relaunch: (...args: unknown[]) => mockRelaunch(...args), +})) + +import { isTauri } from '../mock-tauri' + +describe('useUpdater', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + vi.spyOn(window, 'confirm').mockReturnValue(false) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('does nothing when not in Tauri', () => { + vi.mocked(isTauri).mockReturnValue(false) + renderHook(() => useUpdater()) + vi.advanceTimersByTime(5000) + expect(mockCheck).not.toHaveBeenCalled() + }) + + it('checks for updates after delay when in Tauri', async () => { + vi.mocked(isTauri).mockReturnValue(true) + mockCheck.mockResolvedValue(null) // no update + + renderHook(() => useUpdater()) + expect(mockCheck).not.toHaveBeenCalled() + + // Advance past the 3s delay, then flush microtasks for dynamic imports + await vi.advanceTimersByTimeAsync(3500) + // Dynamic imports are resolved by the mock, but need microtask flush + await vi.waitFor(() => { + expect(mockCheck).toHaveBeenCalledOnce() + }) + }) + + it('shows confirm dialog when update is available', async () => { + vi.mocked(isTauri).mockReturnValue(true) + mockCheck.mockResolvedValue({ + version: '1.2.0', + body: 'Bug fixes and improvements', + downloadAndInstall: vi.fn().mockResolvedValue(undefined), + }) + vi.spyOn(window, 'confirm').mockReturnValue(false) + + renderHook(() => useUpdater()) + await vi.advanceTimersByTimeAsync(3500) + + expect(window.confirm).toHaveBeenCalledWith( + expect.stringContaining('1.2.0') + ) + expect(mockRelaunch).not.toHaveBeenCalled() + }) + + it('downloads, installs, and relaunches when user accepts', async () => { + vi.mocked(isTauri).mockReturnValue(true) + const mockDownloadAndInstall = vi.fn().mockResolvedValue(undefined) + mockCheck.mockResolvedValue({ + version: '1.2.0', + body: '', + downloadAndInstall: mockDownloadAndInstall, + }) + vi.spyOn(window, 'confirm').mockReturnValue(true) + mockRelaunch.mockResolvedValue(undefined) + + renderHook(() => useUpdater()) + await vi.advanceTimersByTimeAsync(3500) + + expect(mockDownloadAndInstall).toHaveBeenCalled() + expect(mockRelaunch).toHaveBeenCalled() + }) + + it('logs warning on check failure without crashing', async () => { + vi.mocked(isTauri).mockReturnValue(true) + mockCheck.mockRejectedValue(new Error('Network error')) + + renderHook(() => useUpdater()) + await vi.advanceTimersByTimeAsync(3500) + + expect(console.warn).toHaveBeenCalledWith( + '[updater] Failed to check for updates:', + expect.any(Error) + ) + }) +}) diff --git a/src/hooks/useUpdater.ts b/src/hooks/useUpdater.ts new file mode 100644 index 00000000..4f63f4b4 --- /dev/null +++ b/src/hooks/useUpdater.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react' +import { isTauri } from '../mock-tauri' + +/** + * Checks for OTA updates on app startup (Tauri only). + * If an update is available, shows a native confirm dialog and + * downloads + installs + relaunches if the user accepts. + */ +export function useUpdater() { + useEffect(() => { + if (!isTauri()) return + + const checkForUpdates = async () => { + try { + const { check } = await import('@tauri-apps/plugin-updater') + const { relaunch } = await import('@tauri-apps/plugin-process') + + const update = await check() + if (!update) return + + const yes = window.confirm( + `A new version (${update.version}) is available.\n\n` + + (update.body ? `${update.body}\n\n` : '') + + 'Do you want to update and restart now?' + ) + if (!yes) return + + await update.downloadAndInstall() + await relaunch() + } catch (err) { + // Silently log — update check failures should never block the app + console.warn('[updater] Failed to check for updates:', err) + } + } + + // Delay slightly so the app can render first + const timer = setTimeout(checkForUpdates, 3000) + return () => clearTimeout(timer) + }, []) +}