Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/specs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions lib/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> ?? {}),
'@tauri-apps/api/window': new URL('./tauri-window-mock.ts', import.meta.url).pathname,
...((config.resolve.alias as Record<string, string>) ?? {}),
'@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;
},
Expand Down
16 changes: 16 additions & 0 deletions lib/.storybook/tauri-stub.ts
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 13 additions & 6 deletions lib/src/lib/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
1 change: 1 addition & 0 deletions lib/src/stories/UpdateBanner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')}
/>
</div>
);
Expand Down
57 changes: 57 additions & 0 deletions lib/src/stories/UpdateDebugDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-app-bg" style={{ width: 800, height: 600, position: 'relative' }}>
<UpdateDebugDialog
key={tick}
open={true}
onClose={() => setTick((t) => t + 1)}
failure={failure}
body={body}
/>
</div>
);
}

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<typeof UpdateDebugDialogStory> = {
title: 'Components/UpdateDebugDialog',
component: UpdateDebugDialogStory,
};

export default meta;
type Story = StoryObj<typeof UpdateDebugDialogStory>;

export const Default: Story = {
args: {
failure: { version: '0.8.0', error: ERROR },
body: BODY,
},
};
5 changes: 4 additions & 1 deletion lib/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
68 changes: 68 additions & 0 deletions scripts/bump-version.sh
Original file line number Diff line number Diff line change
@@ -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 <version>
# Example: ./scripts/bump-version.sh 0.9.0
# =============================================================================

VERSION="${1:-}"
if [[ -z "$VERSION" ]]; then
echo "Usage: $0 <version>" >&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"
12 changes: 11 additions & 1 deletion scripts/sign-and-deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion standalone/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions standalone/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ fn append_log(message: impl AsRef<str>) {
}
}

fn read_log_tail(max_bytes: usize) -> Result<String, String> {
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<u16>,
Expand Down Expand Up @@ -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<String, String> {
read_log_tail(10_000)
}

#[tauri::command]
fn shutdown_sidecar(state: tauri::State<'_, SidecarState>) {
let _ = state.tx.send(SidecarMsg::Shutdown);
Expand Down Expand Up @@ -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")
Expand Down
37 changes: 20 additions & 17 deletions standalone/src/UpdateBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className="flex items-center gap-1.5 pb-1 text-[9px] font-mono tracking-[0.06em] text-muted">
<span className="truncate">{message}</span>
{showChangelog && (
<button
onClick={onOpenChangelog}
className="shrink-0 hover:underline"
style={{ color: 'var(--vscode-textLink-foreground)' }}
>
Changelog
</button>
)}
<button onClick={link.onClick} className={linkClass} style={linkStyle}>
{link.label}
</button>
<button
onClick={onDismiss}
className="shrink-0 rounded p-0.5 hover:bg-foreground/10 hover:text-foreground"
Expand Down
Loading
Loading