Compare commits

...

12 Commits

Author SHA1 Message Date
LAPTOP-O016UC3M\Qi Chen
3876e8c479 Handles master key changes across windows
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Ensures state updates and auto-unlock logic respond correctly when the master key is created or removed in another window. Prevents stale unlock attempts and synchronizes security state, improving multi-window cloud sync reliability.
2026-01-04 15:21:22 +08:00
LAPTOP-O016UC3M\Qi Chen
7143bce61e Improves code style and consistency in settings tab
Refactors indentation and spacing for better readability across the settings application tab and toast UI components.
Enhances maintainability by unifying code formatting and minor whitespace adjustments.
No functional logic is changed.
2026-01-04 15:10:00 +08:00
LAPTOP-O016UC3M\Qi Chen
7c232335ef Adds update check with notifications and improved toast UX
Implements automatic and manual update checking, displaying toast notifications when new versions are available or when the app is up to date. Enhances the toast utility to support clickable actions, custom labels, and improved accessibility. Updates UI to surface update status more prominently and localizes new update-related messages.

Improves user awareness of available updates and provides a smoother update experience.
2026-01-04 14:59:23 +08:00
LAPTOP-O016UC3M\Qi Chen
cc61974744 Localizes Select Host panel UI text
Replaces hardcoded labels with translation keys and adds
missing English and Chinese translations for improved
internationalization and consistency in the Select Host panel.
2026-01-04 14:17:39 +08:00
LAPTOP-O016UC3M\Qi Chen
86de2b2b60 Adds identity support to host selection panels
Enables passing and handling of identity objects in host selection
and forwarding components, improving flexibility in SSH host
configuration and authentication options.
2026-01-04 14:12:16 +08:00
陈大猫
27301f3ecd Merge pull request #6 from Weihong-Liu/feature/utf8-locale-defaults
为本地终端会话补齐 UTF-8 语言环境默认值
2026-01-04 11:59:07 +08:00
Puppet
7f30d7c662 Add UTF-8 locale defaults for terminal sessions 2026-01-04 11:41:57 +08:00
陈大猫
456755a2ec Merge pull request #5 from Weihong-Liu/main
feat: improve quick connect ssh parsing
2026-01-04 10:44:00 +08:00
Puppet
1eee141bed feat: improve quick connect ssh parsing 2026-01-04 10:28:00 +08:00
bincxz
e808a2c51c docs: change screenshot layout to single column 2026-01-02 11:17:53 +08:00
bincxz
15ee1fd020 docs: update download links for v1.0.0 release 2026-01-02 00:12:10 +08:00
bincxz
c3cbf4fbdf Updates dark mode main window screenshot
Refreshes the visual documentation for the dark mode main window to reflect recent UI changes or improvements.
2026-01-02 00:07:13 +08:00
20 changed files with 1132 additions and 137 deletions

23
App.tsx
View File

@@ -3,6 +3,7 @@ import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisi
import { useAutoSync } from './application/state/useAutoSync';
import { useSessionState } from './application/state/useSessionState';
import { useSettingsState } from './application/state/useSettingsState';
import { useUpdateCheck } from './application/state/useUpdateCheck';
import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
@@ -256,6 +257,28 @@ function App({ settings }: { settings: SettingsState }) {
return handleSyncNow({ trigger: 'manual' });
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, openReleasePage, dismissUpdate } = useUpdateCheck();
// Show toast notification when update is available
useEffect(() => {
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
t('update.available.message', { version }),
{
title: t('update.available.title'),
duration: 8000, // Show longer for update notifications
onClick: () => {
openReleasePage();
dismissUpdate();
},
actionLabel: t('update.downloadNow'),
}
);
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;

View File

@@ -14,13 +14,27 @@
</p>
<p align="center">
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
<img src="https://img.shields.io/badge/ダウンロード-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="macOS ARM64 をダウンロード">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
<img src="https://img.shields.io/badge/ダウンロード-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="macOS Intel をダウンロード">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
<img src="https://img.shields.io/badge/ダウンロード-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Windows をダウンロード">
</a>
</p>
<p align="center">
<a href="https://ko-fi.com/binaricat">
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Ko-fi でサポート">
@@ -130,20 +144,30 @@
Vault ビューはすべての SSH 接続を管理するコマンドセンターです。右クリックメニューで階層的なグループを作成し、グループ間でホストをドラッグ、パンくずナビゲーションでホストツリーを素早く移動できます。各ホストは接続状態、OS アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
| ダークモード | ライトモード | リストビュー |
|------------|------------|------------|
| ![ダーク](screenshots/main-window-dark.png) | ![ライト](screenshots/main-window-light.png) | ![リスト](screenshots/main-window-dark-list.png) |
**ダークモード**
![ダークモード](screenshots/main-window-dark.png)
**ライトモード**
![ライトモード](screenshots/main-window-light.png)
**リストビュー**
![リストビュー](screenshots/main-window-dark-list.png)
<a name="ターミナル"></a>
## ターミナル
WebGL アクセラレーション対応の xterm.js ベースのターミナルで、スムーズでレスポンシブな体験を提供。ワークスペースを水平または垂直に分割して、複数のセッションを同時に監視。ブロードキャストモードを有効にすると、すべてのターミナルに一度にコマンドを送信できます — フリート管理に最適。テーマカスタマイズパネルでは、50以上の配色スキームをライブプレビュー、フォントサイズの調整、JetBrains Mono や Fira Code を含む複数のフォントファミリーを選択できます。
| 分割ウィンドウ | テーマカスタマイズ |
|--------------|-----------------|
| ![分割](screenshots/split-window.png) | ![テーマ](screenshots/terminal-theme-change.png) |
**分割ウィンドウ**
![ターミナルテーマ](screenshots/terminal-theme-change-2.png)
![分割ウィンドウ](screenshots/split-window.png)
**テーマカスタマイズ**
![テーマカスタマイズ](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
@@ -245,7 +269,13 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
### ダウンロード
[GitHub Releases](https://github.com/user/netcatty/releases/latest) から最新版をダウンロードしてください。
| プラットフォーム | アーキテクチャ | ダウンロード |
|------------------|----------------|--------------|
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください:
> ```bash
@@ -261,8 +291,8 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
```bash
# リポジトリをクローン
git clone https://github.com/user/netcatty.git
cd netcatty
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# 依存関係をインストール
npm install

View File

@@ -14,13 +14,27 @@
</p>
<p align="center">
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
<img src="https://img.shields.io/badge/Download-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="Download macOS ARM64">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
<img src="https://img.shields.io/badge/Download-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="Download macOS Intel">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
<img src="https://img.shields.io/badge/Download-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Download Windows">
</a>
</p>
<p align="center">
<a href="https://ko-fi.com/binaricat">
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Support on Ko-fi">
@@ -130,20 +144,30 @@
The Vault view is your command center for managing all SSH connections. Create hierarchical groups with right-click context menus, drag hosts between groups, and use breadcrumb navigation to quickly traverse your host tree. Each host displays its connection status, OS icon, and quick-connect button. Switch between grid and list views based on your preference, and use the powerful search to filter hosts by name, hostname, tags, or group.
| Dark Mode | Light Mode | List View |
|-----------|------------|-----------|
| ![Dark](screenshots/main-window-dark.png) | ![Light](screenshots/main-window-light.png) | ![List](screenshots/main-window-dark-list.png) |
**Dark Mode**
![Dark Mode](screenshots/main-window-dark.png)
**Light Mode**
![Light Mode](screenshots/main-window-light.png)
**List View**
![List View](screenshots/main-window-dark-list.png)
<a name="terminal"></a>
## Terminal
Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, responsive experience. Split your workspace horizontally or vertically to monitor multiple sessions simultaneously. Enable broadcast mode to send commands to all terminals at once — perfect for fleet management. The theme customization panel offers 50+ color schemes with live preview, adjustable font size, and multiple font family options including JetBrains Mono and Fira Code.
| Split Windows | Theme Customization |
|---------------|---------------------|
| ![Split](screenshots/split-window.png) | ![Theme](screenshots/terminal-theme-change.png) |
**Split Windows**
![Terminal Themes](screenshots/terminal-theme-change-2.png)
![Split Windows](screenshots/split-window.png)
**Theme Customization**
![Theme Customization](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
@@ -245,7 +269,13 @@ Netcatty automatically detects and displays OS icons for connected hosts:
### Download
Download the latest release from [GitHub Releases](https://github.com/user/netcatty/releases/latest).
| Platform | Architecture | Download |
|----------|--------------|----------|
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
> ```bash
@@ -261,8 +291,8 @@ Download the latest release from [GitHub Releases](https://github.com/user/netca
```bash
# Clone the repository
git clone https://github.com/user/netcatty.git
cd netcatty
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# Install dependencies
npm install

View File

@@ -14,13 +14,27 @@
</p>
<p align="center">
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
<img src="https://img.shields.io/badge/下载-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="下载 macOS ARM64">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
<img src="https://img.shields.io/badge/下载-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="下载 macOS Intel">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
<img src="https://img.shields.io/badge/下载-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="下载 Windows">
</a>
</p>
<p align="center">
<a href="https://ko-fi.com/binaricat">
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="在 Ko-fi 上支持我">
@@ -130,20 +144,30 @@
Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机。
| 深色模式 | 浅色模式 | 列表视图 |
|---------|---------|---------|
| ![深色](screenshots/main-window-dark.png) | ![浅色](screenshots/main-window-light.png) | ![列表](screenshots/main-window-dark-list.png) |
**深色模式**
![深色模式](screenshots/main-window-dark.png)
**浅色模式**
![浅色模式](screenshots/main-window-light.png)
**列表视图**
![列表视图](screenshots/main-window-dark-list.png)
<a name="终端"></a>
## 终端
基于 xterm.js 的 WebGL 加速终端,提供流畅、响应迅速的体验。水平或垂直分割工作区,同时监控多个会话。启用广播模式可一次向所有终端发送命令 —— 非常适合批量管理。主题定制面板提供 50+ 配色方案和实时预览、可调节字号以及多种字体选择,包括 JetBrains Mono 和 Fira Code。
| 分屏窗口 | 主题定制 |
|---------|---------|
| ![分屏](screenshots/split-window.png) | ![主题](screenshots/terminal-theme-change.png) |
**分屏窗口**
![终端主题](screenshots/terminal-theme-change-2.png)
![分屏窗口](screenshots/split-window.png)
**主题定制**
![主题定制](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
@@ -245,7 +269,13 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
### 下载
从 [GitHub Releases](https://github.com/user/netcatty/releases/latest) 下载最新版本。
| 平台 | 架构 | 下载链接 |
|------|------|----------|
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
> **⚠️ macOS 用户注意:** 由于应用未经代码签名macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
> ```bash
@@ -261,8 +291,8 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
```bash
# 克隆仓库
git clone https://github.com/user/netcatty.git
cd netcatty
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# 安装依赖
npm install

View File

@@ -72,6 +72,17 @@ const en: Messages = {
'settings.application.whatsNew': "What's new",
'settings.application.whatsNew.subtitle': 'Show release notes',
// Update notifications
'update.available.title': 'Update Available',
'update.available.message': 'A new version {version} is available. Click to download.',
'update.checking': 'Checking for updates...',
'update.upToDate.title': 'Up to Date',
'update.upToDate.message': 'You are running the latest version ({version}).',
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
// Settings > Appearance
'settings.appearance.uiTheme': 'UI Theme',
'settings.appearance.darkMode': 'Dark Mode',
@@ -246,7 +257,7 @@ const en: Messages = {
'vault.hosts.header.live': '{count} live',
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname...',
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Connect',
'vault.view.grid': 'Grid',
'vault.view.list': 'List',
@@ -431,6 +442,9 @@ const en: Messages = {
// Select Host panel
'selectHost.title': 'Select Host',
'selectHost.noHostsFound': 'No hosts found',
'selectHost.newHost': 'New Host',
'selectHost.continue': 'Continue',
'selectHost.continueWithCount': 'Continue ({count} selected)',
// Quick Connect
'quickConnect.knownHost.title': 'Are you sure you want to connect?',
@@ -439,6 +453,7 @@ const en: Messages = {
'quickConnect.knownHost.addQuestion': 'Do you want to add it to the list of known hosts?',
'quickConnect.knownHost.addAndContinue': 'Add and continue',
'quickConnect.addKey': 'Add key',
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
// Terminal
'terminal.connectionErrorTitle': 'Connection Error',

View File

@@ -60,6 +60,17 @@ const zhCN: Messages = {
'settings.application.whatsNew': '更新内容',
'settings.application.whatsNew.subtitle': '查看发布说明',
// Update notifications
'update.available.title': '发现新版本',
'update.available.message': '新版本 {version} 已发布,点击前往下载。',
'update.checking': '正在检查更新...',
'update.upToDate.title': '已是最新版本',
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
'update.error': '检查更新失败',
'update.downloadNow': '立即下载',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
// Settings > Appearance
'settings.appearance.uiTheme': '界面主题',
'settings.appearance.darkMode': '深色模式',
@@ -148,7 +159,7 @@ const zhCN: Messages = {
'vault.hosts.header.live': '{count} 个在线',
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname…',
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '连接',
'vault.view.grid': '网格',
'vault.view.list': '列表',
@@ -304,6 +315,9 @@ const zhCN: Messages = {
// Select Host panel
'selectHost.title': '选择主机',
'selectHost.noHostsFound': '未找到主机',
'selectHost.newHost': '新建主机',
'selectHost.continue': '继续',
'selectHost.continueWithCount': '继续(已选 {count} 个)',
// Quick Connect
'quickConnect.knownHost.title': '确认要连接吗?',
@@ -312,6 +326,7 @@ const zhCN: Messages = {
'quickConnect.knownHost.addQuestion': '是否将它加入 Known Hosts',
'quickConnect.knownHost.addAndContinue': '加入并继续',
'quickConnect.addKey': '添加 key',
'quickConnect.warning.unparsedOptions': '部分 SSH 参数已被忽略: {options}',
// Protocol select dialog
'protocolSelect.chooseProtocol': '选择协议',

View File

@@ -142,8 +142,21 @@ export const useCloudSync = (): CloudSyncHook => {
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
// and unlock silently so users don't have to manage a LOCKED state in the UI.
// Track the master key config hash to detect when a new master key is set up in another window.
const lastMasterKeyHashRef = useRef<string | null>(null);
const attemptedAutoUnlockRef = useRef(false);
useEffect(() => {
// Compute a simple hash of the master key config to detect changes
const currentHash = state.masterKeyConfig
? JSON.stringify({ salt: state.masterKeyConfig.salt, iv: state.masterKeyConfig.iv })
: null;
// If master key config changed (e.g., set up in settings window), reset the attempt flag
if (currentHash !== lastMasterKeyHashRef.current) {
lastMasterKeyHashRef.current = currentHash;
attemptedAutoUnlockRef.current = false;
}
if (attemptedAutoUnlockRef.current) return;
if (state.securityState !== 'LOCKED') return;
attemptedAutoUnlockRef.current = true;
@@ -162,7 +175,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Ignore auto-unlock errors; manual actions will surface them.
}
})();
}, [state.securityState]);
}, [state.securityState, state.masterKeyConfig]);
// ========== Computed Values ==========

View File

@@ -0,0 +1,265 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
// Delay startup check to avoid slowing down app launch
const STARTUP_CHECK_DELAY_MS = 5000;
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
// Debug logging for update checks
const debugLog = (...args: unknown[]) => {
if (IS_UPDATE_DEMO_MODE || (typeof window !== 'undefined' && window.localStorage?.getItem('debug.updateCheck') === '1')) {
console.log('[UpdateCheck]', ...args);
}
};
export interface UpdateState {
isChecking: boolean;
hasUpdate: boolean;
currentVersion: string;
latestRelease: ReleaseInfo | null;
error: string | null;
lastCheckedAt: number | null;
}
export interface UseUpdateCheckResult {
updateState: UpdateState;
checkNow: () => Promise<UpdateCheckResult | null>;
dismissUpdate: () => void;
openReleasePage: () => void;
}
/**
* Hook for managing update checks
* - Automatically checks for updates on startup (with delay)
* - Respects dismissed version to avoid nagging
* - Provides manual check capability
*/
export function useUpdateCheck(): UseUpdateCheckResult {
const [updateState, setUpdateState] = useState<UpdateState>({
isChecking: false,
hasUpdate: false,
currentVersion: '',
latestRelease: null,
error: null,
lastCheckedAt: null,
});
const hasCheckedOnStartupRef = useRef(false);
const isCheckingRef = useRef(false);
const startupCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Get current app version
useEffect(() => {
const loadVersion = async () => {
try {
const bridge = netcattyBridge.get();
const info = await bridge?.getAppInfo?.();
if (info?.version) {
setUpdateState((prev) => ({ ...prev, currentVersion: info.version }));
}
} catch {
// Ignore - running without Electron bridge
}
};
void loadVersion();
}, []);
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
// In demo mode, use a fake version to allow checking
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersion;
if (!effectiveVersion || effectiveVersion === '0.0.0') {
debugLog('Skipping check - invalid version:', effectiveVersion);
// Skip check for dev builds
return null;
}
if (isCheckingRef.current) {
debugLog('Already checking, skipping');
return null;
}
isCheckingRef.current = true;
setUpdateState((prev) => ({ ...prev, isChecking: true, error: null }));
try {
let result: UpdateCheckResult;
if (IS_UPDATE_DEMO_MODE) {
debugLog('Demo mode: creating fake update result');
// Simulate a short delay like a real API call
await new Promise(resolve => setTimeout(resolve, 500));
// In demo mode, create a fake update result
result = {
hasUpdate: true,
currentVersion: '0.0.1',
latestRelease: {
version: '1.0.0',
tagName: 'v1.0.0',
name: 'Netcatty v1.0.0',
body: 'Demo release for testing update notification',
htmlUrl: 'https://github.com/binaricat/Netcatty/releases',
publishedAt: new Date().toISOString(),
assets: [],
},
};
} else {
result = await checkForUpdates(currentVersion);
}
debugLog('Check result:', result);
debugLog('Latest release version:', result.latestRelease?.version);
const now = Date.now();
// Save last check time
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
// Check if this version was dismissed
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const showUpdate = result.hasUpdate &&
result.latestRelease?.version !== dismissedVersion;
debugLog('Show update:', showUpdate, 'dismissed version:', dismissedVersion);
debugLog('Setting state with hasUpdate:', showUpdate);
setUpdateState((prev) => {
debugLog('State updated:', { ...prev, hasUpdate: showUpdate, latestRelease: result.latestRelease });
return {
...prev,
isChecking: false,
hasUpdate: showUpdate,
latestRelease: result.latestRelease,
error: result.error || null,
lastCheckedAt: now,
};
});
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
setUpdateState((prev) => ({
...prev,
isChecking: false,
error: errorMsg,
}));
return null;
} finally {
isCheckingRef.current = false;
}
}, []);
const checkNow = useCallback(async () => {
// In demo mode, use fake version to allow checking
const version = IS_UPDATE_DEMO_MODE ? '0.0.1' : updateState.currentVersion;
return performCheck(version);
}, [performCheck, updateState.currentVersion]);
const dismissUpdate = useCallback(() => {
if (updateState.latestRelease?.version) {
localStorageAdapter.writeString(
STORAGE_KEY_UPDATE_DISMISSED_VERSION,
updateState.latestRelease.version
);
}
setUpdateState((prev) => ({ ...prev, hasUpdate: false }));
}, [updateState.latestRelease?.version]);
const openReleasePage = useCallback(async () => {
const url = updateState.latestRelease
? getReleaseUrl(updateState.latestRelease.version)
: getReleaseUrl();
try {
const bridge = netcattyBridge.get();
if (bridge?.openExternal) {
await bridge.openExternal(url);
return;
}
} catch {
// Fallback to window.open
}
window.open(url, '_blank', 'noopener,noreferrer');
}, [updateState.latestRelease]);
// Startup check with delay - runs once on mount
useEffect(() => {
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
// In demo mode, trigger check immediately after a short delay
if (IS_UPDATE_DEMO_MODE) {
debugLog('Demo mode: scheduling update check in', STARTUP_CHECK_DELAY_MS, 'ms');
startupCheckTimeoutRef.current = setTimeout(() => {
debugLog('=== Demo mode: Triggering update check ===');
void performCheck('0.0.1');
}, STARTUP_CHECK_DELAY_MS);
return () => {
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
}
};
}
// Normal mode: wait for version to be loaded, then check
// This is handled by the version-dependent effect below
}, [performCheck]);
// Normal mode startup check - depends on currentVersion
useEffect(() => {
// Skip in demo mode (handled above)
if (IS_UPDATE_DEMO_MODE) {
return;
}
debugLog('Version check effect', {
hasChecked: hasCheckedOnStartupRef.current,
currentVersion: updateState.currentVersion
});
if (hasCheckedOnStartupRef.current) {
return;
}
if (!updateState.currentVersion || updateState.currentVersion === '0.0.0') {
return;
}
// Check if we've checked recently
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
const now = Date.now();
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
hasCheckedOnStartupRef.current = true;
return;
}
hasCheckedOnStartupRef.current = true;
debugLog('Starting delayed update check for version:', updateState.currentVersion);
startupCheckTimeoutRef.current = setTimeout(() => {
debugLog('=== Delayed check triggered ===');
void performCheck(updateState.currentVersion);
}, STARTUP_CHECK_DELAY_MS);
return () => {
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
}
};
}, [updateState.currentVersion, performCheck]);
return {
updateState,
checkNow,
dismissUpdate,
openReleasePage,
};
}

View File

@@ -53,6 +53,7 @@ type WizardStep =
interface PortForwardingProps {
hosts: Host[];
keys: SSHKey[];
identities?: import('../domain/models').Identity[];
customGroups: string[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
@@ -62,6 +63,7 @@ interface PortForwardingProps {
const PortForwarding: React.FC<PortForwardingProps> = ({
hosts,
keys,
identities = [],
customGroups: _customGroups,
onNewHost: _onNewHost,
onSaveHost,
@@ -796,9 +798,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onBack={() => setShowHostSelector(false)}
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
identities={identities}
onSaveHost={onSaveHost}
onCreateGroup={_onCreateGroup}
title="Select Host"
/>
)}

View File

@@ -31,6 +31,7 @@ interface QuickConnectWizardProps {
target: QuickConnectTarget;
keys: SSHKey[];
knownHosts: KnownHost[];
warnings?: string[];
onConnect: (host: Host) => void;
onSaveHost?: (host: Host) => void;
onAddKey?: () => void;
@@ -42,6 +43,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
target,
keys,
knownHosts,
warnings,
onConnect,
onSaveHost,
onAddKey,
@@ -644,6 +646,16 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
{/* Progress indicator */}
<div className="px-6">{renderProgressIndicator()}</div>
{warnings && warnings.length > 0 && (
<div className="px-6 pb-2">
<div className="text-xs text-amber-600 bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2">
{t("quickConnect.warning.unparsedOptions", {
options: warnings.join(", "),
})}
</div>
</div>
)}
{/* Content */}
<div className="px-6 py-4">
{step === "protocol" && renderProtocolStep()}

View File

@@ -30,6 +30,7 @@ interface SelectHostPanelProps {
onNewHost?: () => void;
// Props for inline host creation
availableKeys?: SSHKey[];
identities?: import('../domain/models').Identity[];
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
title?: string;
@@ -47,6 +48,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onContinue,
onNewHost,
availableKeys = [],
identities = [],
onSaveHost,
onCreateGroup,
title,
@@ -203,7 +205,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
return (
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
className,
)}
>
@@ -247,7 +249,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}}
>
<Plus size={14} />
NEW HOST
{t('selectHost.newHost')}
</Button>
)}
<div className="relative flex-1 max-w-xs">
@@ -256,7 +258,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search"
placeholder={t('common.searchPlaceholder')}
className="h-8 pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -393,8 +395,8 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}}
>
{multiSelect
? `Continue (${selectedHostIds.length} selected)`
: "Continue"}
? t('selectHost.continueWithCount', { count: selectedHostIds.length })
: t('selectHost.continue')}
</Button>
</div>
@@ -403,6 +405,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
<HostDetailsPanel
initialData={null}
availableKeys={availableKeys}
identities={identities}
groups={customGroups}
allHosts={hosts}
onSave={(host) => {

View File

@@ -1,91 +1,145 @@
import React, { useEffect, useMemo, useState } from "react";
import { Bug, Github, MessageCircle, Newspaper, RefreshCcw } from "lucide-react";
import { ArrowUpCircle, Bug, Check, Github, Loader2, MessageCircle, Newspaper, RefreshCcw } from "lucide-react";
import AppLogo from "./AppLogo";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { useI18n } from "../application/i18n/I18nProvider";
import { SettingsTabContent } from "./settings/settings-ui";
import { toast } from "./ui/toast";
type AppInfo = {
name: string;
version: string;
platform?: string;
name: string;
version: string;
platform?: string;
};
const REPO_URL = "https://github.com/binaricat/Netcatty";
const buildIssueUrl = (appInfo: AppInfo) => {
const title = "Bug: ";
const bodyLines = [
"## Describe the problem",
"",
"## Steps to reproduce",
"1.",
"",
"## Expected behavior",
"",
"## Actual behavior",
"",
"## Environment",
`- App: ${appInfo.name} ${appInfo.version}`,
`- Platform: ${appInfo.platform || "unknown"}`,
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
];
const params = new URLSearchParams({
title,
body: bodyLines.join("\n"),
});
return `${REPO_URL}/issues/new?${params.toString()}`;
const title = "Bug: ";
const bodyLines = [
"## Describe the problem",
"",
"## Steps to reproduce",
"1.",
"",
"## Expected behavior",
"",
"## Actual behavior",
"",
"## Environment",
`- App: ${appInfo.name} ${appInfo.version}`,
`- Platform: ${appInfo.platform || "unknown"}`,
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
];
const params = new URLSearchParams({
title,
body: bodyLines.join("\n"),
});
return `${REPO_URL}/issues/new?${params.toString()}`;
};
const ActionRow: React.FC<{
icon: React.ReactNode;
title: string;
subtitle: string;
onClick: () => void;
icon: React.ReactNode;
title: string;
subtitle: string;
onClick: () => void;
}> = ({ icon, title, subtitle, onClick }) => (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 rounded-lg px-3 py-3 text-left",
"hover:bg-muted/50 transition-colors"
)}
>
<div className="shrink-0 text-muted-foreground">{icon}</div>
<div className="min-w-0">
<div className="text-sm font-medium leading-tight">{title}</div>
<div className="text-xs text-muted-foreground mt-0.5 truncate">{subtitle}</div>
</div>
</button>
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 rounded-lg px-3 py-3 text-left",
"hover:bg-muted/50 transition-colors"
)}
>
<div className="shrink-0 text-muted-foreground">{icon}</div>
<div className="min-w-0">
<div className="text-sm font-medium leading-tight">{title}</div>
<div className="text-xs text-muted-foreground mt-0.5 truncate">{subtitle}</div>
</div>
</button>
);
export default function SettingsApplicationTab() {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const { updateState, checkNow, openReleasePage } = useUpdateCheck();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
const [hasAutoChecked, setHasAutoChecked] = useState(false);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const info = await getApplicationInfo();
if (!cancelled && info?.name && typeof info?.version === "string") {
setAppInfo(info);
}
} catch {
// Ignore: running in browser/dev without Electron bridge
}
};
void load();
return () => {
cancelled = true;
};
}, [getApplicationInfo]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const info = await getApplicationInfo();
if (!cancelled && info?.name && typeof info?.version === "string") {
setAppInfo(info);
}
} catch {
// Ignore: running in browser/dev without Electron bridge
}
};
void load();
return () => {
cancelled = true;
};
}, [getApplicationInfo]);
const issueUrl = useMemo(() => buildIssueUrl(appInfo), [appInfo]);
const releasesUrl = `${REPO_URL}/releases`;
// Check if demo mode is enabled for development testing
const isUpdateDemoMode = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
// Auto check for updates when entering this page
useEffect(() => {
if (hasAutoChecked) return;
if (updateState.isChecking) return;
// In demo mode or when we have a valid version, auto-check
const canCheck = isUpdateDemoMode || (appInfo.version && appInfo.version !== '0.0.0');
if (!canCheck) return;
setHasAutoChecked(true);
void checkNow();
}, [hasAutoChecked, updateState.isChecking, isUpdateDemoMode, appInfo.version, checkNow]);
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
// Dev build - just open releases page
openReleasePage();
return;
}
setLastCheckResult('none');
const result = await checkNow();
if (result?.hasUpdate && result.latestRelease) {
setLastCheckResult('available');
toast.info(
t('update.available.message', { version: result.latestRelease.version }),
t('update.available.title')
);
// Open the release page
openReleasePage();
} else if (result) {
setLastCheckResult('upToDate');
toast.success(
t('update.upToDate.message', { version: appInfo.version }),
t('update.upToDate.title')
);
}
// Reset the result after 3 seconds
setTimeout(() => setLastCheckResult('none'), 3000);
};
const issueUrl = useMemo(() => buildIssueUrl(appInfo), [appInfo]);
const releasesUrl = `${REPO_URL}/releases`;
const discussionsUrl = `${REPO_URL}/discussions`;
return (
@@ -96,16 +150,46 @@ export default function SettingsApplicationTab() {
<AppLogo className="w-16 h-16" />
<div>
<div className="text-3xl font-semibold leading-none">{appInfo.name}</div>
<div className="text-sm text-muted-foreground mt-1">
{appInfo.version ? appInfo.version : " "}
<div className="flex items-center gap-2 mt-1">
<span className="text-sm text-muted-foreground">
{appInfo.version ? appInfo.version : " "}
</span>
{/* Update available badge - inline with version */}
{updateState.hasUpdate && updateState.latestRelease && (
<button
onClick={() => void openReleasePage()}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
"bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
"hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer"
)}
>
<ArrowUpCircle size={12} />
v{updateState.latestRelease.version} {t('update.downloadNow')}
</button>
)}
</div>
</div>
</div>
<div className="mt-6">
<Button variant="secondary" className="gap-2" onClick={() => void openExternal(releasesUrl)}>
<RefreshCcw size={16} />
{t("settings.application.checkUpdates")}
<Button
variant="secondary"
className="gap-2"
onClick={() => void handleCheckForUpdates()}
disabled={updateState.isChecking}
>
{updateState.isChecking ? (
<Loader2 size={16} className="animate-spin" />
) : lastCheckResult === 'upToDate' ? (
<Check size={16} />
) : (
<RefreshCcw size={16} />
)}
{updateState.isChecking
? t("update.checking")
: t("settings.application.checkUpdates")
}
</Button>
</div>
</div>

View File

@@ -46,7 +46,7 @@ import KeychainManager from "./KeychainManager";
import KnownHostsManager from "./KnownHostsManager";
import PortForwarding from "./PortForwardingNew";
import QuickConnectWizard from "./QuickConnectWizard";
import { isQuickConnectInput, parseQuickConnectInput } from "../domain/quickConnect";
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
import SnippetsManager from "./SnippetsManager";
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
import { Button } from "./ui/button";
@@ -184,6 +184,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
port?: number;
} | null>(null);
const [isQuickConnectOpen, setIsQuickConnectOpen] = useState(false);
const [quickConnectWarnings, setQuickConnectWarnings] = useState<string[]>([]);
// Protocol select state (for hosts with multiple protocols)
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
@@ -198,9 +199,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Handle connect button click - detect quick connect or regular search
const handleConnectClick = useCallback(() => {
if (isSearchQuickConnect) {
const target = parseQuickConnectInput(search);
if (target) {
setQuickConnectTarget(target);
const parsed = parseQuickConnectInputWithWarnings(search);
if (parsed.target) {
setQuickConnectTarget(parsed.target);
setQuickConnectWarnings(parsed.warnings);
setIsQuickConnectOpen(true);
}
} else {
@@ -268,6 +270,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onConnect(host);
setIsQuickConnectOpen(false);
setQuickConnectTarget(null);
setQuickConnectWarnings([]);
setSearch("");
},
[onConnect],
@@ -1308,6 +1311,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<PortForwarding
hosts={hosts}
keys={keys}
identities={identities}
customGroups={customGroups}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onCreateGroup={(groupPath) =>
@@ -1483,7 +1487,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onClose={() => {
setIsQuickConnectOpen(false);
setQuickConnectTarget(null);
setQuickConnectWarnings([]);
}}
warnings={quickConnectWarnings}
/>
)}

View File

@@ -1,5 +1,5 @@
import { AlertCircle,AlertTriangle,CheckCircle,Info,X } from 'lucide-react';
import React,{ createContext,useCallback,useContext,useEffect,useState } from 'react';
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { cn } from '../../lib/utils';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
@@ -10,6 +10,8 @@ export interface Toast {
title?: string;
message: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
interface ToastContextValue {
@@ -31,18 +33,29 @@ export const useToast = () => {
// Simple hook for components that may not be inside ToastProvider
let globalShowToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
export interface ToastOptions {
title?: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
export const toast = {
success: (message: string, title?: string) => {
globalShowToast?.({ type: 'success', message, title, duration: 3000 });
success: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'success', message, duration: 3000, ...options });
},
error: (message: string, title?: string) => {
globalShowToast?.({ type: 'error', message, title, duration: 5000 });
error: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'error', message, duration: 5000, ...options });
},
warning: (message: string, title?: string) => {
globalShowToast?.({ type: 'warning', message, title, duration: 4000 });
warning: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'warning', message, duration: 4000, ...options });
},
info: (message: string, title?: string) => {
globalShowToast?.({ type: 'info', message, title, duration: 3000 });
info: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'info', message, duration: 3000, ...options });
},
};
@@ -99,6 +112,13 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => void }> = ({ toasts, onDismiss }) => {
if (toasts.length === 0) return null;
const handleToastClick = (t: Toast) => {
if (t.onClick) {
t.onClick();
onDismiss(t.id);
}
};
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
@@ -107,8 +127,12 @@ const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => voi
className={cn(
"flex items-start gap-3 p-3 rounded-lg border shadow-lg",
"bg-card animate-in slide-in-from-right-5 fade-in duration-200",
TOAST_STYLES[t.type]
TOAST_STYLES[t.type],
t.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
)}
onClick={() => handleToastClick(t)}
role={t.onClick ? "button" : undefined}
tabIndex={t.onClick ? 0 : undefined}
>
<div className="flex-shrink-0 mt-0.5">
{TOAST_ICONS[t.type]}
@@ -118,9 +142,12 @@ const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => voi
<div className="text-sm font-medium text-foreground">{t.title}</div>
)}
<div className="text-sm text-muted-foreground break-words">{t.message}</div>
{t.actionLabel && t.onClick && (
<div className="text-xs font-medium text-primary mt-1">{t.actionLabel} </div>
)}
</div>
<button
onClick={() => onDismiss(t.id)}
onClick={(e) => { e.stopPropagation(); onDismiss(t.id); }}
className="flex-shrink-0 p-1 rounded hover:bg-secondary/80 transition-colors"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />

View File

@@ -4,10 +4,12 @@ export interface QuickConnectTarget {
port?: number;
}
// Parse user@host:port format
export function parseQuickConnectInput(
input: string,
): QuickConnectTarget | null {
export interface QuickConnectParseResult {
target: QuickConnectTarget | null;
warnings: string[];
}
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
const trimmed = input.trim();
if (!trimmed) return null;
@@ -43,10 +45,230 @@ export function parseQuickConnectInput(
username: username || undefined,
port,
};
};
const sshArgOptions = new Set([
"-b",
"-c",
"-D",
"-E",
"-F",
"-i",
"-I",
"-J",
"-L",
"-m",
"-O",
"-P",
"-R",
"-S",
"-W",
"-w",
]);
const parseSshOption = (
raw: string,
nextToken?: string,
): { key: string; value: string; consumedNext: boolean } | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
const parts = trimmed.split("=");
if (parts.length >= 2) {
const key = parts[0]?.trim();
const value = parts.slice(1).join("=").trim();
if (key && value) {
return { key, value, consumedNext: false };
}
}
if (nextToken && !nextToken.startsWith("-")) {
return { key: trimmed, value: nextToken, consumedNext: true };
}
return null;
};
const parseSshCommand = (input: string): QuickConnectParseResult | null => {
const trimmed = input.trim();
if (!/^ssh(\s|$)/i.test(trimmed)) return null;
const tokens = trimmed.split(/\s+/);
if (tokens.length < 2) return null;
const warnings: string[] = [];
let username: string | undefined;
let optionUsername: string | undefined;
let port: number | undefined;
let optionPort: number | undefined;
let portInvalid = false;
let optionHostname: string | undefined;
let hostToken: string | undefined;
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
if (!token) continue;
if (token === "-p") {
const value = tokens[i + 1];
if (value) {
port = parseInt(value, 10);
if (Number.isNaN(port)) portInvalid = true;
i++;
}
continue;
}
if (token.startsWith("-p") && token.length > 2) {
const value = token.replace(/^-p=?/, "");
if (value) {
port = parseInt(value, 10);
if (Number.isNaN(port)) portInvalid = true;
}
continue;
}
if (token === "-l") {
const value = tokens[i + 1];
if (value) {
username = value;
i++;
}
continue;
}
if (token.startsWith("-l") && token.length > 2) {
const value = token.replace(/^-l=?/, "");
if (value) username = value;
continue;
}
if (token === "-o") {
const optionToken = tokens[i + 1];
if (optionToken) {
const nextToken = tokens[i + 2];
const parsed = parseSshOption(optionToken, nextToken);
if (parsed) {
const key = parsed.key.toLowerCase();
if (key === "port") {
const parsedPort = parseInt(parsed.value, 10);
if (Number.isNaN(parsedPort)) {
portInvalid = true;
} else {
optionPort = parsedPort;
}
} else if (key === "user") {
optionUsername = parsed.value;
} else if (key === "hostname") {
optionHostname = parsed.value;
} else {
warnings.push(`-o ${parsed.key}`);
}
i += parsed.consumedNext ? 2 : 1;
continue;
}
warnings.push("-o");
i++;
}
continue;
}
if (token.startsWith("-o") && token.length > 2) {
const parsed = parseSshOption(token.slice(2), tokens[i + 1]);
if (parsed) {
const key = parsed.key.toLowerCase();
if (key === "port") {
const parsedPort = parseInt(parsed.value, 10);
if (Number.isNaN(parsedPort)) {
portInvalid = true;
} else {
optionPort = parsedPort;
}
} else if (key === "user") {
optionUsername = parsed.value;
} else if (key === "hostname") {
optionHostname = parsed.value;
} else {
warnings.push(`-o ${parsed.key}`);
}
if (parsed.consumedNext) i++;
continue;
}
warnings.push("-o");
}
if (sshArgOptions.has(token)) {
warnings.push(token);
const next = tokens[i + 1];
if (next) i++;
continue;
}
if (token.startsWith("-")) {
warnings.push(token);
continue;
}
if (!hostToken) {
hostToken = token;
} else {
warnings.push(token);
}
}
if (!hostToken) return null;
const base = optionHostname
? parseDirectTarget(optionHostname)
: parseDirectTarget(hostToken);
if (!base) return null;
if (portInvalid) return null;
const resolvedPort =
port !== undefined && !Number.isNaN(port)
? port
: optionPort !== undefined && !Number.isNaN(optionPort)
? optionPort
: base.port;
if (
resolvedPort !== undefined &&
(Number.isNaN(resolvedPort) || resolvedPort < 1 || resolvedPort > 65535)
) {
return null;
}
return {
target: {
hostname: base.hostname,
username: optionUsername || username || base.username,
port: resolvedPort,
},
warnings: Array.from(new Set(warnings)),
};
};
// Parse user@host:port or ssh command formats with warning details
export function parseQuickConnectInputWithWarnings(
input: string,
): QuickConnectParseResult {
const trimmed = input.trim();
if (!trimmed) return { target: null, warnings: [] };
const sshTarget = parseSshCommand(trimmed);
if (sshTarget) return sshTarget;
return { target: parseDirectTarget(trimmed), warnings: [] };
}
// Parse user@host:port or ssh command formats
export function parseQuickConnectInput(
input: string,
): QuickConnectTarget | null {
return parseQuickConnectInputWithWarnings(input).target;
}
// Check if input looks like a quick connect address
export function isQuickConnectInput(input: string): boolean {
return parseQuickConnectInput(input) !== null;
}

View File

@@ -12,6 +12,8 @@ const pty = require("node-pty");
let sessions = null;
let electronModule = null;
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
/**
* Initialize the terminal bridge with dependencies
*/
@@ -52,6 +54,32 @@ function findExecutable(name) {
return name;
}
const isUtf8Locale = (value) => typeof value === "string" && /utf-?8/i.test(value);
const isEmptyLocale = (value) => {
if (value === undefined || value === null) return true;
const trimmed = String(value).trim();
if (!trimmed) return true;
return trimmed === "C" || trimmed === "POSIX";
};
const applyLocaleDefaults = (env) => {
const hasUtf8 =
isUtf8Locale(env.LC_ALL) || isUtf8Locale(env.LC_CTYPE) || isUtf8Locale(env.LANG);
if (hasUtf8) return env;
const hasAnyLocale =
!isEmptyLocale(env.LC_ALL) || !isEmptyLocale(env.LC_CTYPE) || !isEmptyLocale(env.LANG);
if (hasAnyLocale) return env;
return {
...env,
LANG: DEFAULT_UTF8_LOCALE,
LC_CTYPE: DEFAULT_UTF8_LOCALE,
LC_ALL: DEFAULT_UTF8_LOCALE,
};
};
/**
* Start a local terminal session
*/
@@ -63,12 +91,12 @@ function startLocalSession(event, payload) {
? findExecutable("powershell") || "powershell.exe"
: process.env.SHELL || "/bin/bash";
const shell = payload?.shell || defaultShell;
const env = {
const env = applyLocaleDefaults({
...process.env,
...(payload?.env || {}),
TERM: "xterm-256color",
COLORTERM: "truecolor",
};
});
const proc = pty.spawn(shell, [], {
cols: payload?.cols || 80,

View File

@@ -31,5 +31,9 @@ export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
// Update check
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';

View File

@@ -217,6 +217,26 @@ export class CloudSyncManager {
const key = event.key;
if (!key) return;
// Handle master key config changes (e.g., when set up in settings window)
if (key === SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG) {
const nextConfig = this.safeJsonParse<MasterKeyConfig>(event.newValue);
if (nextConfig && !this.state.masterKeyConfig) {
// Master key was set up in another window - update our state
this.state.masterKeyConfig = nextConfig;
this.state.securityState = 'LOCKED';
this.notifyStateChange();
} else if (!nextConfig && this.state.masterKeyConfig) {
// Master key was removed in another window
this.state.masterKeyConfig = null;
this.state.securityState = 'NO_KEY';
this.state.unlockedKey = null;
this.masterPassword = null;
this.notifyStateChange();
}
return;
}
// Sync versions + auto-sync settings
if (key === SYNC_STORAGE_KEYS.SYNC_CONFIG) {
const next = this.safeJsonParse<{

View File

@@ -0,0 +1,166 @@
/**
* Update Service - Checks GitHub releases for new versions
*/
const GITHUB_API_URL = 'https://api.github.com/repos/binaricat/Netcatty/releases/latest';
const RELEASES_PAGE_URL = 'https://github.com/binaricat/Netcatty/releases';
export interface ReleaseInfo {
version: string; // e.g. "1.0.0" (without 'v' prefix)
tagName: string; // e.g. "v1.0.0"
name: string; // Release title
body: string; // Release notes (markdown)
htmlUrl: string; // URL to the release page
publishedAt: string; // ISO date string
assets: ReleaseAsset[];
}
export interface ReleaseAsset {
name: string;
browserDownloadUrl: string;
size: number;
}
export interface UpdateCheckResult {
hasUpdate: boolean;
currentVersion: string;
latestRelease: ReleaseInfo | null;
error?: string;
}
/**
* Parse version string to comparable array
* e.g. "1.2.3" -> [1, 2, 3]
*/
function parseVersion(version: string): number[] {
// Remove 'v' prefix if present
const clean = version.replace(/^v/i, '');
return clean.split('.').map((part) => {
const num = parseInt(part, 10);
return isNaN(num) ? 0 : num;
});
}
/**
* Compare two version strings
* Returns: 1 if a > b, -1 if a < b, 0 if equal
*/
export function compareVersions(a: string, b: string): number {
const partsA = parseVersion(a);
const partsB = parseVersion(b);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const numA = partsA[i] ?? 0;
const numB = partsB[i] ?? 0;
if (numA > numB) return 1;
if (numA < numB) return -1;
}
return 0;
}
/**
* Fetch the latest release info from GitHub
*/
export async function fetchLatestRelease(): Promise<ReleaseInfo | null> {
try {
const response = await fetch(GITHUB_API_URL, {
headers: {
'Accept': 'application/vnd.github.v3+json',
// Using anonymous access - rate limited to 60 requests/hour
},
});
if (!response.ok) {
if (response.status === 404) {
// No releases yet
return null;
}
throw new Error(`GitHub API error: ${response.status}`);
}
const data = await response.json();
return {
version: data.tag_name?.replace(/^v/i, '') || '0.0.0',
tagName: data.tag_name || '',
name: data.name || data.tag_name || '',
body: data.body || '',
htmlUrl: data.html_url || RELEASES_PAGE_URL,
publishedAt: data.published_at || '',
assets: (data.assets || []).map((asset: { name?: string; browser_download_url?: string; size?: number }) => ({
name: asset.name || '',
browserDownloadUrl: asset.browser_download_url || '',
size: asset.size || 0,
})),
};
} catch (error) {
console.warn('[UpdateService] Failed to fetch latest release:', error);
return null;
}
}
/**
* Check for updates
*/
export async function checkForUpdates(currentVersion: string): Promise<UpdateCheckResult> {
const result: UpdateCheckResult = {
hasUpdate: false,
currentVersion,
latestRelease: null,
};
try {
const release = await fetchLatestRelease();
if (!release) {
return result;
}
result.latestRelease = release;
result.hasUpdate = compareVersions(release.version, currentVersion) > 0;
return result;
} catch (error) {
result.error = error instanceof Error ? error.message : 'Unknown error';
return result;
}
}
/**
* Get release page URL for a specific version
*/
export function getReleaseUrl(version?: string): string {
if (version) {
return `${RELEASES_PAGE_URL}/tag/v${version.replace(/^v/i, '')}`;
}
return RELEASES_PAGE_URL;
}
/**
* Get download URL for current platform
*/
export function getDownloadUrlForPlatform(
release: ReleaseInfo,
platform: string
): string | null {
const assets = release.assets;
// Platform-specific file patterns
const patterns: Record<string, RegExp[]> = {
win32: [/\.exe$/i, /win.*\.zip$/i, /windows/i],
darwin: [/\.dmg$/i, /mac.*\.zip$/i, /darwin/i],
linux: [/\.AppImage$/i, /\.deb$/i, /linux/i],
};
const platformPatterns = patterns[platform] || [];
for (const pattern of platformPatterns) {
const asset = assets.find((a) => pattern.test(a.name));
if (asset) {
return asset.browserDownloadUrl;
}
}
// Fallback to release page
return null;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

After

Width:  |  Height:  |  Size: 875 KiB