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
67 changes: 55 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ jobs:
run: flutter test

# ── macOS ──────────────────────────────────────────────────────────────────
# Apple Silicon only (macos-latest = arm64). Intel (macos-13) dropped due
# to chronic runner unavailability and near-zero M-chip user base.
# One universal (arm64 + x86_64) build from the arm64 runner: Flutter builds
# both slices by default and build.sh lipos the Rust dylib, so Intel Macs are
# covered without depending on GitHub's flaky Intel runners.
build-macos:
needs: test
runs-on: macos-latest
Expand All @@ -56,9 +57,13 @@ jobs:
working-directory: app
run: flutter pub get

# --split-debug-info strips Dart symbols from the AOT snapshot into
# build/symbols (20-30% smaller binary). Crash stack traces become raw
# offsets — symbolicate with: flutter symbolize -d <unzipped symbols>.
# The per-release *-symbols.zip attached below is the only copy; keep it.
- name: Build macOS
working-directory: app
run: flutter build macos --release
run: flutter build macos --release --split-debug-info=build/symbols

- name: Bundle RDP library into app
run: |
Expand All @@ -67,6 +72,20 @@ jobs:
cp packages/yourssh_rdp/assets/native/macos/libyourssh_rdp.dylib \
"$APP/Contents/Frameworks/"

# Fail fast if either the app or the Rust dylib lost an architecture —
# an arm64-only artifact named "universal" would brick Intel installs.
- name: Verify universal binaries
run: |
APP=$(find "app/build/macos/Build/Products/Release" -name "*.app" -maxdepth 1 | head -1)
for BIN in "$APP/Contents/MacOS/"* "$APP/Contents/Frameworks/libyourssh_rdp.dylib"; do
ARCHS=$(lipo -archs "$BIN")
echo "$BIN: $ARCHS"
case "$ARCHS" in
*arm64*x86_64*|*x86_64*arm64*) ;;
*) echo "::error::$BIN is not universal ($ARCHS)"; exit 1 ;;
esac
done

- name: Extract version
id: version
run: |
Expand All @@ -76,7 +95,12 @@ jobs:
- name: Create ZIP
run: |
APP=$(find "app/build/macos/Build/Products/Release" -name "*.app" -maxdepth 1 | head -1)
zip -r "YourSSH-${{ steps.version.outputs.version }}-macOS-arm64.zip" "$APP"
zip -r "YourSSH-${{ steps.version.outputs.version }}-macOS-universal.zip" "$APP"

- name: Zip debug symbols
run: |
cd app/build/symbols
zip -r "$GITHUB_WORKSPACE/YourSSH-${{ steps.version.outputs.version }}-macOS-universal-symbols.zip" .

- name: Install create-dmg
run: brew install create-dmg
Expand All @@ -89,15 +113,16 @@ jobs:
--window-size 600 400 \
--icon-size 128 \
--app-drop-link 450 185 \
"YourSSH-${{ steps.version.outputs.version }}-macOS-arm64.dmg" \
"YourSSH-${{ steps.version.outputs.version }}-macOS-universal.dmg" \
"$APP"

- uses: actions/upload-artifact@v4
with:
name: macos-arm64
name: macos-universal
path: |
YourSSH-${{ steps.version.outputs.version }}-macOS-arm64.zip
YourSSH-${{ steps.version.outputs.version }}-macOS-arm64.dmg
YourSSH-${{ steps.version.outputs.version }}-macOS-universal.zip
YourSSH-${{ steps.version.outputs.version }}-macOS-universal.dmg
YourSSH-${{ steps.version.outputs.version }}-macOS-universal-symbols.zip

# ── Windows ────────────────────────────────────────────────────────────────
# x64 is the standard target. arm64 (Windows on ARM / Snapdragon) is
Expand Down Expand Up @@ -144,9 +169,10 @@ jobs:
working-directory: app
run: flutter pub get

# Dart symbols split out for size — see the macOS build step comment.
- name: Build Windows
working-directory: app
run: flutter build windows --release
run: flutter build windows --release --split-debug-info=build/symbols

- name: Bundle RDP library into release
shell: pwsh
Expand Down Expand Up @@ -188,6 +214,14 @@ jobs:
Compress-Archive -Path "app\build\windows\$arch\runner\Release\*" `
-DestinationPath "YourSSH-${ver}-Windows-${arch}-portable.zip"

- name: Zip debug symbols
shell: pwsh
run: |
$ver = "${{ steps.version.outputs.version }}"
$arch = "${{ matrix.arch }}"
Compress-Archive -Path "app\build\symbols\*" `
-DestinationPath "YourSSH-${ver}-Windows-${arch}-symbols.zip"

- name: Install Inno Setup (ARM64 runner only)
if: matrix.arch == 'arm64'
shell: pwsh
Expand Down Expand Up @@ -225,6 +259,7 @@ jobs:
path: |
YourSSH-${{ steps.version.outputs.version }}-Windows-${{ matrix.arch }}-portable.zip
YourSSH.Setup.${{ steps.version.outputs.version }}-Windows-${{ matrix.arch }}.exe
YourSSH-${{ steps.version.outputs.version }}-Windows-${{ matrix.arch }}-symbols.zip

# ── Linux ──────────────────────────────────────────────────────────────────
# Produces a .deb package and a portable tar.gz for both x86_64 and arm64.
Expand Down Expand Up @@ -281,9 +316,10 @@ jobs:
working-directory: app
run: flutter pub get

# Dart symbols split out for size — see the macOS build step comment.
- name: Build Linux
working-directory: app
run: flutter build linux --release
run: flutter build linux --release --split-debug-info=build/symbols

- name: Extract version
id: version
Expand Down Expand Up @@ -347,12 +383,18 @@ jobs:
tar -czf "YourSSH-${VERSION}-Linux-${ARCH}.tar.gz" \
-C "$(dirname "$BUNDLE")" "$(basename "$BUNDLE")"

- name: Zip debug symbols
run: |
cd app/build/symbols
zip -r "$GITHUB_WORKSPACE/YourSSH-${{ steps.version.outputs.version }}-Linux-${{ matrix.arch }}-symbols.zip" .

- uses: actions/upload-artifact@v4
with:
name: linux-${{ matrix.arch }}
path: |
yourssh_${{ steps.version.outputs.version }}_${{ matrix.deb_arch }}.deb
YourSSH-${{ steps.version.outputs.version }}-Linux-${{ matrix.arch }}.tar.gz
YourSSH-${{ steps.version.outputs.version }}-Linux-${{ matrix.arch }}-symbols.zip

# ── GitHub Release ─────────────────────────────────────────────────────────
release:
Expand Down Expand Up @@ -389,8 +431,8 @@ jobs:
generate_release_notes: true
fail_on_unmatched_files: false
files: |
YourSSH-${{ steps.version.outputs.version }}-macOS-arm64.zip
YourSSH-${{ steps.version.outputs.version }}-macOS-arm64.dmg
YourSSH-${{ steps.version.outputs.version }}-macOS-universal.zip
YourSSH-${{ steps.version.outputs.version }}-macOS-universal.dmg
YourSSH-${{ steps.version.outputs.version }}-Windows-x64-portable.zip
YourSSH.Setup.${{ steps.version.outputs.version }}-Windows-x64.exe
YourSSH-${{ steps.version.outputs.version }}-Windows-arm64-portable.zip
Expand All @@ -399,3 +441,4 @@ jobs:
YourSSH-${{ steps.version.outputs.version }}-Linux-x86_64.tar.gz
yourssh_${{ steps.version.outputs.version }}_arm64.deb
YourSSH-${{ steps.version.outputs.version }}-Linux-arm64.tar.gz
YourSSH-${{ steps.version.outputs.version }}-*-symbols.zip
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Flutter UI (widgets/screens)
- `RecordingService` — writes asciicast v2 (`.cast`) files; tracks active recordings keyed by `sessionId`; passive intercept pattern — `SshService` always calls `writeOutput()` / `onShellClosed()`, which no-op when not recording; when `redact:` is on (effective = `SettingsProvider.recordingRedactionEnabled` AND `Host.recordingRedaction`, both default true; sampled once at start via `RecordingProvider.redactionPolicy` wired in main.dart with a fresh `HostProvider` lookup — the session's Host snapshot goes stale after a panel edit), output is line-buffered (split at the last newline, start-once `flushDelay` timer, default 500 ms, stop flushes the tail) and passed through `AuditRedactor.redact()` before writing — coalesces events per line, which also strips keystroke timing; ANSI escapes inside a secret and a secret straddling a flushDelay boundary defeat the regexes (defense-in-depth, not a guarantee)
- `ShellIntegrationService` — pure (no Flutter/IO): `parseOsc(code, args)` maps xterm `onPrivateOSC` to a typed `ShellOscEvent` (OSC 7 cwd, OSC 133 A/D; C ignored); `buildInjectionScript()` is the guarded one-line bash/zsh prompt-hook installer (auto-on, opt-out via `Host.shellIntegration` + `SettingsProvider.shellIntegrationEnabled`), delivered **invisibly** via a two-phase handshake: `buildBootstrapLine()` (short line that disables tty echo and blocks in `read -rs`, printing `__YS_RDY__`/`__YS_DONE__` sentinels) + `buildPayloadLine({includeInstaller, workingDir, envVars})` (the installer plus the per-host session-template setup — `cd -- '<dir>'` + `export K='v'`, single-quote-escaped via `shQuote`, keys checked by `isValidEnvKey`; a failing cd prints a warning placed *after* the DONE sentinel so it survives the gate discard; consumed by `read` so never echoed). `SshService.openShell` wires `terminal.onPrivateOSC` and injects only when `injection_gate.dart`'s `InjectionReadiness` confirms the line editor is reading (bracketed-paste `ESC[?2004h` toggle + settle, bare-`\n` probe fallback for bash ≤ 5.0; skipped on alt-screen/user typing/never-confirmed) while `InjectionGate` withholds and discards the bootstrap echo; `path_completion.dart` (pure) plans cwd-aware path completion for the input bar over `SshService.listDirectory`. Design: `docs/superpowers/specs/2026-06-03-invisible-shell-integration-design.md`
- `AuditService` / `AuditRedactor` — local SQLite audit trail (`sqlite3` + `sqlite3_flutter_libs`, WAL, `<app-support>/audit.db`): `connect`/`disconnect`/`exec`/`input` events with denormalized host fields; commands pass `AuditRedactor` (pure regex masking: `key=value` secrets incl. prefixed `PGPASSWORD=`, Bearer tokens, `sshpass -p`, mysql/mariadb attached `-p`, URL userinfo — psql `-p` is the port, deliberately excluded) **before** insert; every write fail-soft (never breaks SSH ops); `SshService.exec` takes `auditSource` (`'app'` default; `'bulk'`/`'devops'`/`'plugin:<id>'`/`'plugin:js'` threaded by callers; `null` = skip — used by the network-stats poll so it can't flood the log); connect failures logged only when no retry is scheduled; retention pruned at startup (`auditRetentionDays`, default 90, 0 = forever); CSV/JSON export of the filtered view
- `UpdateService` — in-app update glue: `fetchLatestRelease()` (GitHub `releases/latest`, stable-only), pure `isNewerVersion` (semver, fail-closed on blank) and `assetForPlatform` (OS/arch → release asset; macOS arm64-only → null on Intel), `downloadAsset` (streamed to Downloads with progress, cleans up partial file), `launchInstaller` (macOS: strip `com.apple.quarantine` + `open` DMG; Windows: run installer `.exe`; Linux: `xdg-open`); throws typed `UpdateException`; takes an injectable `http.Client` for testing
- `UpdateService` — in-app update glue: `fetchLatestRelease()` (GitHub `releases/latest`, stable-only), pure `isNewerVersion` (semver, fail-closed on blank) and `assetForPlatform` (OS/arch → release asset; macOS ships a universal DMG — Intel only matches it, falling back to the browser on pre-universal releases), `downloadAsset` (streamed to Downloads with progress, cleans up partial file), `launchInstaller` (macOS: strip `com.apple.quarantine` + `open` DMG; Windows: run installer `.exe`; Linux: `xdg-open`); throws typed `UpdateException`; takes an injectable `http.Client` for testing

**Utils** (`app/lib/util/`):
- `file_mode.dart` — POSIX mode helpers: `modeToOctal` / `parseOctal` (3–4 octal digits only — shorter is a partially-typed mode and never parses), the 9 `kMode*` permission-bit constants used by `PermissionsDialog`'s rwx grid, and `chmodLocal` (system `chmod`, macOS/Linux; callers hide the menu item on Windows)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ YourSSH also checks for new releases on launch and from **Settings → Updates**

| Platform | File |
|---|---|
| macOS (Apple Silicon) | `YourSSH-x.x.x-macOS-arm64.dmg` |
| macOS (Apple Silicon & Intel) | `YourSSH-x.x.x-macOS-universal.dmg` |
| Windows (x64) | `YourSSH.Setup.x.x.x-Windows-x64.exe` |
| Windows (ARM64 — Surface, Snapdragon) | `YourSSH.Setup.x.x.x-Windows-arm64.exe` |
| Linux (Debian/Ubuntu — x86_64) | `yourssh_x.x.x_amd64.deb` |
Expand Down
Binary file removed app/assets/fonts/nerd/MesloLGS NF Bold Italic.ttf
Binary file not shown.
Binary file removed app/assets/fonts/nerd/MesloLGS NF Italic.ttf
Binary file not shown.
1 change: 1 addition & 0 deletions app/lib/providers/session_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ class SessionProvider extends ChangeNotifier {
}

void setActive(String sessionId) {
if (_activeSessionId == sessionId) return; // re-clicking the active tab
_activeSessionId = sessionId;
_safeNotify();
}
Expand Down
27 changes: 27 additions & 0 deletions app/lib/providers/settings_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xterm/xterm.dart' as xterm;

import '../models/keyword_highlight_rule.dart';
import '../models/shell_profile.dart';
Expand Down Expand Up @@ -54,6 +55,32 @@ class SettingsProvider extends ChangeNotifier {
bool keywordHighlightingEnabled = true;
List<AppKeywordHighlightRule> keywordHighlightRules = kDefaultKeywordHighlightRules;

List<xterm.KeywordHighlightRule>? _xtermRulesCache;
List<AppKeywordHighlightRule>? _xtermRulesSource;
bool _xtermRulesEnabled = false;

/// The enabled rules compiled for xterm, shared by every terminal surface.
/// Memoized: [keywordHighlightRules] is only ever replaced wholesale (load
/// and updateSettings assign a new list, never mutate), so list identity +
/// the enabled flag fully key the cache — without it each terminal build
/// recompiled every rule's RegExp. The stable list identity also lets
/// rebuild diffing treat the rules as unchanged.
List<xterm.KeywordHighlightRule> get xtermKeywordRules {
if (!identical(_xtermRulesSource, keywordHighlightRules) ||
_xtermRulesEnabled != keywordHighlightingEnabled) {
_xtermRulesSource = keywordHighlightRules;
_xtermRulesEnabled = keywordHighlightingEnabled;
_xtermRulesCache = keywordHighlightingEnabled
? keywordHighlightRules
.where((r) => r.enabled)
.map((r) => r.toXtermRule())
.whereType<xterm.KeywordHighlightRule>()
.toList(growable: false)
: const [];
}
return _xtermRulesCache!;
}

List<ShellProfile> get allShellProfiles =>
[...detectedShellProfiles, ...customShellProfiles];

Expand Down
7 changes: 4 additions & 3 deletions app/lib/services/system_agent_proxy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,10 @@ class _AgentWriter {

Uint8List buildMessage() {
final body = _buf.toBytes();
final header = Uint8List(4);
ByteData.view(header.buffer).setUint32(0, body.length, Endian.big);
return Uint8List.fromList([...header, ...body]);
final message = Uint8List(4 + body.length);
ByteData.view(message.buffer).setUint32(0, body.length, Endian.big);
message.setRange(4, message.length, body);
return message;
}
}

Expand Down
24 changes: 15 additions & 9 deletions app/lib/services/update_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ class UpdateService {

/// Picks the best matching asset for [os] (`macos`/`windows`/`linux`) and
/// [arch] (`arm64`/`x64`/`amd64`). Returns null when no artifact matches
/// (e.g. macOS x64 — only arm64 is shipped); callers then fall back to the
/// browser. For each platform the candidate names are tried in preference
/// order and the first asset whose name matches is returned.
/// (e.g. macOS x64 against a pre-universal release that only shipped
/// arm64); callers then fall back to the browser. For each platform the
/// candidate names are tried in preference order and the first asset whose
/// name matches is returned.
ReleaseAsset? assetForPlatform(
AppRelease release, {
required String os,
Expand All @@ -75,7 +76,12 @@ class UpdateService {
List<String> candidates() {
switch (os) {
case 'macos':
return arch == 'arm64' ? const ['macOS-arm64.dmg'] : const [];
// Universal DMG serves both archs; arm64 also accepts the
// arm64-only name older releases shipped. Intel must not — an
// arm64-only DMG would install but never launch there.
return arch == 'arm64'
? const ['macOS-universal.dmg', 'macOS-arm64.dmg']
: const ['macOS-universal.dmg'];
case 'linux':
return arch == 'arm64'
? const ['_arm64.deb', 'Linux-arm64.tar.gz']
Expand Down Expand Up @@ -137,13 +143,13 @@ class UpdateService {
}

/// CPU architecture token used by [assetForPlatform].
/// macOS only ships arm64; Windows reads PROCESSOR_ARCHITECTURE; Linux
/// shells out to `uname -m`.
/// macOS shells out to `uname -m`; Windows reads PROCESSOR_ARCHITECTURE;
/// Linux shells out to `uname -m`.
String currentArch() {
if (Platform.isMacOS) {
// Only arm64 artifacts are published. Detect the real arch so Intel Macs
// return 'x64' -> assetForPlatform returns null -> caller falls back to
// the browser, rather than being handed an arm64-only DMG.
// Detect the real arch so Intel Macs only accept the universal DMG —
// against a pre-universal (arm64-only) release they fall back to the
// browser rather than being handed a DMG that can't launch.
try {
final m = Process.runSync('uname', const ['-m']).stdout.toString().trim();
return (m == 'arm64' || m == 'aarch64') ? 'arm64' : 'x64';
Expand Down
8 changes: 1 addition & 7 deletions app/lib/widgets/local_terminal_pane.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,7 @@ class _LocalTerminalPaneState extends State<LocalTerminalPane> {

Widget _terminal(BuildContext context) {
final settings = context.watch<SettingsProvider>();
final keywordRules = settings.keywordHighlightingEnabled
? settings.keywordHighlightRules
.where((r) => r.enabled)
.map((r) => r.toXtermRule())
.whereType<KeywordHighlightRule>()
.toList()
: const <KeywordHighlightRule>[];
final keywordRules = settings.xtermKeywordRules;
return Stack(
children: [
TerminalView(
Expand Down
8 changes: 1 addition & 7 deletions app/lib/widgets/recording_player_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,7 @@ class _RecordingPlayerWidgetState extends State<RecordingPlayerWidget> {
final settings = context.watch<SettingsProvider>();
final theme = terminalThemeByName(settings.terminalTheme);
final progress = _events.isEmpty ? 0.0 : _currentIndex / _events.length;
final keywordRules = settings.keywordHighlightingEnabled
? settings.keywordHighlightRules
.where((r) => r.enabled)
.map((r) => r.toXtermRule())
.whereType<KeywordHighlightRule>()
.toList()
: const <KeywordHighlightRule>[];
final keywordRules = settings.xtermKeywordRules;

return Column(
children: [
Expand Down
8 changes: 1 addition & 7 deletions app/lib/widgets/terminal_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -430,13 +430,7 @@ class _TerminalWidgetState extends State<_TerminalWidget> {
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>();
final keywordRules = settings.keywordHighlightingEnabled
? settings.keywordHighlightRules
.where((r) => r.enabled)
.map((r) => r.toXtermRule())
.whereType<KeywordHighlightRule>()
.toList()
: const <KeywordHighlightRule>[];
final keywordRules = settings.xtermKeywordRules;
final appearance = _appearance(watch: true);
final theme = terminalThemeByName(appearance.themeName);
final showGutter = settings.shellIntegrationEnabled &&
Expand Down
Loading
Loading