diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index c5db642..ed3b23b 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -17,8 +17,9 @@ Human-driven steps, in order: 1. **Update dependencies page** — run `node website/scripts/generate-deps.js` and review the diff in `website/src/data/dependencies.json`. Commit if changed. 2. **Finalize changelog** — promote the `[Unreleased]` section in `CHANGELOG.md` to `[X.Y.Z]` with today's date. Write release notes covering both standalone and VSCode changes. -3. **Bump versions** — update `version` in all three places: +3. **Bump versions** — run `./scripts/bump-version.sh X.Y.Z`. This edits all four files in lockstep and runs `cargo check` so `Cargo.lock`'s `mouseterm` entry stays in sync: - [standalone/src-tauri/Cargo.toml](../../standalone/src-tauri/Cargo.toml) + - [standalone/src-tauri/Cargo.lock](../../standalone/src-tauri/Cargo.lock) (auto-synced by cargo) - [standalone/src-tauri/tauri.conf.json](../../standalone/src-tauri/tauri.conf.json) - [vscode-ext/package.json](../../vscode-ext/package.json) - [lib/package.json](../../lib/package.json) diff --git a/lib/.storybook/main.ts b/lib/.storybook/main.ts index 8c9cfa0..fc6b3a3 100644 --- a/lib/.storybook/main.ts +++ b/lib/.storybook/main.ts @@ -1,13 +1,24 @@ import type { StorybookConfig } from '@storybook/react-vite'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); const config: StorybookConfig = { stories: ['../src/**/*.stories.@(ts|tsx)'], framework: '@storybook/react-vite', viteFinal: (config) => { + const stub = path.resolve(here, 'tauri-stub.ts'); + const windowMock = path.resolve(here, 'tauri-window-mock.ts'); config.resolve ??= {}; config.resolve.alias = { - ...(config.resolve.alias as Record ?? {}), - '@tauri-apps/api/window': new URL('./tauri-window-mock.ts', import.meta.url).pathname, + ...((config.resolve.alias as Record) ?? {}), + '@tauri-apps/api/window': windowMock, + '@tauri-apps/api/app': stub, + '@tauri-apps/api/core': stub, + '@tauri-apps/plugin-shell': stub, + '@tauri-apps/plugin-updater': stub, + 'mouseterm-lib': path.resolve(here, '..', 'src'), }; return config; }, diff --git a/lib/.storybook/tauri-stub.ts b/lib/.storybook/tauri-stub.ts new file mode 100644 index 0000000..dc10662 --- /dev/null +++ b/lib/.storybook/tauri-stub.ts @@ -0,0 +1,16 @@ +// Stubs for @tauri-apps/* imports during Storybook builds. The real packages +// only run inside a Tauri webview; in Storybook we just need the named exports +// to evaluate without crashing. One shared file backs aliases for several +// Tauri packages — each import resolves the names it needs. + +export const check = async () => null; +export const getVersion = async () => '0.0.0-storybook'; +export const open = async (url: string) => { + console.log('[storybook] tauri shell open:', url); +}; +export const invoke = async (cmd: string) => { + console.log('[storybook] tauri invoke:', cmd); + return ''; +}; + +export type Update = unknown; diff --git a/lib/src/lib/platform/index.ts b/lib/src/lib/platform/index.ts index 1f89a8e..3b0a544 100644 --- a/lib/src/lib/platform/index.ts +++ b/lib/src/lib/platform/index.ts @@ -17,16 +17,23 @@ export { } from './fake-scenarios'; /** - * True when running on macOS. Used to pick native keyboard conventions - * (Cmd vs Ctrl for copy/paste, etc.). Computed once at module load. + * Best available platform identifier from the browser. Prefers the + * UA-Client-Hints `userAgentData.platform` (e.g. "macOS", "Windows"), + * falling back to the legacy `navigator.platform`, then `userAgent`. + * Empty string in non-browser environments. Computed once at module load. */ -export const IS_MAC: boolean = (() => { - if (typeof navigator === 'undefined') return false; +export const PLATFORM_STRING: string = (() => { + if (typeof navigator === 'undefined') return ''; const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; - const platform = nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent ?? ''; - return /Mac|iPhone|iPad/i.test(platform); + return nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent ?? ''; })(); +/** + * True when running on macOS. Used to pick native keyboard conventions + * (Cmd vs Ctrl for copy/paste, etc.). + */ +export const IS_MAC: boolean = /Mac|iPhone|iPad/i.test(PLATFORM_STRING); + let adapter: PlatformAdapter | null = null; /** Set an externally-created platform adapter (e.g. TauriAdapter from standalone). */ diff --git a/lib/src/stories/UpdateBanner.stories.tsx b/lib/src/stories/UpdateBanner.stories.tsx index 461093d..29f9021 100644 --- a/lib/src/stories/UpdateBanner.stories.tsx +++ b/lib/src/stories/UpdateBanner.stories.tsx @@ -8,6 +8,7 @@ function UpdateBannerStory({ state }: { state: UpdateBannerState }) { state={state} onDismiss={() => console.log('Dismiss')} onOpenChangelog={() => console.log('Open changelog')} + onOpenDebug={() => console.log('Open debug')} /> ); diff --git a/lib/src/stories/UpdateDebugDialog.stories.tsx b/lib/src/stories/UpdateDebugDialog.stories.tsx new file mode 100644 index 0000000..27039e0 --- /dev/null +++ b/lib/src/stories/UpdateDebugDialog.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { UpdateDebugDialog } from '../../../standalone/src/UpdateDebugDialog'; + +interface StoryArgs { + failure: { version: string; error?: string }; + body: string | null; +} + +function UpdateDebugDialogStory({ failure, body }: StoryArgs) { + // Bumping `key` on close re-mounts the dialog so the story stays interactive + // after the user dismisses it (otherwise the canvas goes blank). + const [tick, setTick] = useState(0); + return ( +
+ setTick((t) => t + 1)} + failure={failure} + body={body} + /> +
+ ); +} + +const ERROR = 'EACCES: permission denied at /Applications/MouseTerm.app'; + +const BODY = [ + '**App version**: 0.7.0 → 0.8.0', + '**Platform**: macOS', + `**Error**: ${ERROR}`, + '', + '**Recent log:**', + '```', + '[42] [app] setup started', + '[42] [sidecar] resolved script: /path/to/sidecar/main.js', + '[42] [sidecar] spawned Node.js runtime (pid=12345)', + '[42] [app] sidecar state registered', + '```', + '', +].join('\n'); + +const meta: Meta = { + title: 'Components/UpdateDebugDialog', + component: UpdateDebugDialogStory, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + failure: { version: '0.8.0', error: ERROR }, + body: BODY, + }, +}; diff --git a/lib/tsconfig.app.json b/lib/tsconfig.app.json index 01c31e2..0ff1f49 100644 --- a/lib/tsconfig.app.json +++ b/lib/tsconfig.app.json @@ -14,7 +14,10 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "paths": { + "mouseterm-lib/*": ["./src/*"] + } }, "include": ["src"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..572400b --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# Bump version across all release artifacts and sync Cargo.lock. +# ============================================================================= +# Edits the four version files in lockstep, then runs cargo so Cargo.lock's +# `mouseterm` entry follows along. Print a diff stat for review. +# +# Usage: ./scripts/bump-version.sh +# Example: ./scripts/bump-version.sh 0.9.0 +# ============================================================================= + +VERSION="${1:-}" +if [[ -z "$VERSION" ]]; then + echo "Usage: $0 " >&2 + echo " Example: $0 0.9.0" >&2 + exit 2 +fi + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "Error: '$VERSION' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)" >&2 + exit 2 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +CARGO_TOML="standalone/src-tauri/Cargo.toml" +TAURI_CONF="standalone/src-tauri/tauri.conf.json" +VSCODE_PKG="vscode-ext/package.json" +LIB_PKG="lib/package.json" +CARGO_LOCK="standalone/src-tauri/Cargo.lock" + +# Cargo.toml: the `[package]` version line. Anchored to column 0 so we can't +# match `version = "..."` inside an inline dependency table. +toml_matches=$(grep -c '^version = ' "$CARGO_TOML") +if [[ "$toml_matches" -ne 1 ]]; then + echo "Error: expected exactly 1 '^version = ' line in $CARGO_TOML, found $toml_matches" >&2 + exit 1 +fi +perl -i -pe 's/^version\s*=\s*".*"/version = "'"$VERSION"'"/' "$CARGO_TOML" + +# package.json / tauri.conf.json: replace only the first `"version": "..."` +# (the package version), leaving any nested deps or schema versions alone. +for f in "$TAURI_CONF" "$VSCODE_PKG" "$LIB_PKG"; do + if ! grep -q '"version":' "$f"; then + echo "Error: no '\"version\":' line in $f" >&2 + exit 1 + fi + perl -i -pe 'if (!$done && s/"version":\s*"[^"]*"/"version": "'"$VERSION"'"/) { $done = 1 }' "$f" +done + +# Sync Cargo.lock by running cargo. `cargo check` is idempotent and updates +# the lockfile's mouseterm entry to match the bumped Cargo.toml. Without this, +# Cargo.lock keeps the old version and ships out of sync with the binary. +echo "Syncing Cargo.lock (cargo check)…" +( cd standalone/src-tauri && cargo check --offline >/dev/null ) + +echo +echo "Bumped to v$VERSION." +git --no-pager diff --stat -- \ + "$CARGO_TOML" "$TAURI_CONF" "$VSCODE_PKG" "$LIB_PKG" "$CARGO_LOCK" +echo +echo "Review: git diff -- $CARGO_TOML $TAURI_CONF $VSCODE_PKG $LIB_PKG $CARGO_LOCK" +echo "Commit: git commit -am 'Release v$VERSION'" +echo "Tag: git tag v$VERSION" diff --git a/scripts/sign-and-deploy.sh b/scripts/sign-and-deploy.sh index ad94b67..df5daa6 100755 --- a/scripts/sign-and-deploy.sh +++ b/scripts/sign-and-deploy.sh @@ -493,7 +493,17 @@ notarize_macos() { app_name=$(basename "$app") log "Creating $FNAME_MAC..." - tar -czf "$SIGN_DIR/$FNAME_MAC" -C "$(dirname "$app")" "$app_name" + # COPYFILE_DISABLE=1 stops macOS's tar from writing ._* AppleDouble + # sidecar files (resource-fork metadata) into the archive. Without + # this the Tauri updater's extraction fails with + # `failed to unpack ._MouseTerm.app`. + COPYFILE_DISABLE=1 tar -czf "$SIGN_DIR/$FNAME_MAC" -C "$(dirname "$app")" "$app_name" + + # Defense in depth: if any ._* slipped in anyway, fail loudly here + # rather than shipping a tarball the updater can't unpack. + if tar -tzf "$SIGN_DIR/$FNAME_MAC" | grep -E '(^|/)\._' >/dev/null; then + error "$FNAME_MAC contains AppleDouble (._*) entries — macOS metadata leaked into the archive" + fi fi log "All macOS notarization and packaging complete" diff --git a/standalone/src-tauri/Cargo.lock b/standalone/src-tauri/Cargo.lock index f64ed23..3062329 100644 --- a/standalone/src-tauri/Cargo.lock +++ b/standalone/src-tauri/Cargo.lock @@ -1938,7 +1938,7 @@ dependencies = [ [[package]] name = "mouseterm" -version = "0.7.0" +version = "0.8.0" dependencies = [ "libc", "serde", diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 44904ff..21c63da 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -88,6 +88,21 @@ fn append_log(message: impl AsRef) { } } +fn read_log_tail(max_bytes: usize) -> Result { + let path = default_log_path(); + let contents = std::fs::read_to_string(&path) + .map_err(|e| format!("read {}: {e}", path.display()))?; + if contents.len() <= max_bytes { + return Ok(contents); + } + // Slice on a char boundary so we never split a multi-byte sequence. + let start = contents.len() - max_bytes; + let start = (start..contents.len()) + .find(|&i| contents.is_char_boundary(i)) + .unwrap_or(contents.len()); + Ok(contents[start..].to_string()) +} + #[derive(Serialize, Deserialize, Clone)] struct PtySpawnOptions { cols: Option, @@ -239,6 +254,11 @@ fn read_clipboard_image_as_file_path( .and_then(|path| path.as_str().map(String::from))) } +#[tauri::command] +fn read_update_log() -> Result { + read_log_tail(10_000) +} + #[tauri::command] fn shutdown_sidecar(state: tauri::State<'_, SidecarState>) { let _ = state.tx.send(SidecarMsg::Shutdown); @@ -532,6 +552,7 @@ pub fn run() { get_available_shells, read_clipboard_file_paths, read_clipboard_image_as_file_path, + read_update_log, ]) .build(tauri::generate_context!()) .expect("error while building MouseTerm") diff --git a/standalone/src/UpdateBanner.tsx b/standalone/src/UpdateBanner.tsx index ad67b5a..1a9488e 100644 --- a/standalone/src/UpdateBanner.tsx +++ b/standalone/src/UpdateBanner.tsx @@ -5,46 +5,49 @@ export type UpdateBannerState = | { status: 'downloaded'; version: string } | { status: 'dismissed' } | { status: 'post-update-success'; from: string; to: string } - | { status: 'post-update-failure'; version: string }; + | { status: 'post-update-failure'; version: string; error?: string }; interface UpdateBannerProps { state: UpdateBannerState; onDismiss: () => void; onOpenChangelog: () => void; + onOpenDebug: () => void; } -export function UpdateBanner({ state, onDismiss, onOpenChangelog }: UpdateBannerProps) { +const linkClass = 'shrink-0 hover:underline'; +const linkStyle = { color: 'var(--vscode-textLink-foreground)' }; + +export function UpdateBanner({ state, onDismiss, onOpenChangelog, onOpenDebug }: UpdateBannerProps) { if (state.status === 'idle' || state.status === 'dismissed') return null; let message: string; - let showChangelog = false; + let link: { label: string; onClick: () => void }; switch (state.status) { case 'downloaded': - message = `Update downloaded (v${state.version}) \u2014 will install when you quit.`; - showChangelog = true; + message = `Update downloaded (v${state.version}) — will install when you quit.`; + link = { label: 'Changelog', onClick: onOpenChangelog }; break; case 'post-update-success': - message = `Updated to v${state.to} \u2014 from v${state.from}.`; - showChangelog = true; + message = `Updated to v${state.to} — from v${state.from}.`; + link = { label: 'Changelog', onClick: onOpenChangelog }; break; case 'post-update-failure': - message = `Update to v${state.version} failed \u2014 will retry next launch.`; + message = 'Update failed.'; + link = { label: 'Click here to debug', onClick: onOpenDebug }; break; + default: { + const _exhaustive: never = state; + return _exhaustive; + } } return ( {message} - {showChangelog && ( - - )} + + + +
+
+

+ We couldn't install v{failure.version}. The error was: +

+
+            {errorPreview || '(no error captured)'}
+          
+
+ +
+

1. Search existing reports

+

+ Someone may have already hit this — a quick search saves a duplicate report. +

+ +
+ +
+

2. File a new bug

+

+ If you can't find an existing bug,{' '} + + {copied && — copied!} + {' '}and paste it into a new issue. +

+