Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff6fa55829 | ||
|
|
a9fabf6677 | ||
|
|
aa42468ccd | ||
|
|
242c420961 | ||
|
|
abdac05db6 | ||
|
|
84809b37a7 | ||
|
|
2cdd83d6f1 | ||
|
|
0ecb51ea17 | ||
|
|
326e613e82 | ||
|
|
71aaeba17b | ||
|
|
9d2e19a034 | ||
|
|
fcdf5bce32 | ||
|
|
ea655d95a3 | ||
|
|
be5110f306 | ||
|
|
3876e8c479 | ||
|
|
7143bce61e | ||
|
|
7c232335ef | ||
|
|
cc61974744 | ||
|
|
86de2b2b60 | ||
|
|
27301f3ecd | ||
|
|
7f30d7c662 | ||
|
|
456755a2ec | ||
|
|
1eee141bed | ||
|
|
e808a2c51c | ||
|
|
15ee1fd020 | ||
|
|
c3cbf4fbdf |
23
App.tsx
23
App.tsx
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
|
||||
|
||||
| ダークモード | ライトモード | リストビュー |
|
||||
|------------|------------|------------|
|
||||
|  |  |  |
|
||||
**ダークモード**
|
||||
|
||||

|
||||
|
||||
**ライトモード**
|
||||
|
||||

|
||||
|
||||
**リストビュー**
|
||||
|
||||

|
||||
|
||||
<a name="ターミナル"></a>
|
||||
## ターミナル
|
||||
|
||||
WebGL アクセラレーション対応の xterm.js ベースのターミナルで、スムーズでレスポンシブな体験を提供。ワークスペースを水平または垂直に分割して、複数のセッションを同時に監視。ブロードキャストモードを有効にすると、すべてのターミナルに一度にコマンドを送信できます — フリート管理に最適。テーマカスタマイズパネルでは、50以上の配色スキームをライブプレビュー、フォントサイズの調整、JetBrains Mono や Fira Code を含む複数のフォントファミリーを選択できます。
|
||||
|
||||
| 分割ウィンドウ | テーマカスタマイズ |
|
||||
|--------------|-----------------|
|
||||
|  |  |
|
||||
**分割ウィンドウ**
|
||||
|
||||

|
||||

|
||||
|
||||
**テーマカスタマイズ**
|
||||
|
||||

|
||||
|
||||
<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
|
||||
|
||||
52
README.md
52
README.md
@@ -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>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 Mode**
|
||||
|
||||

|
||||
|
||||
**Light Mode**
|
||||
|
||||

|
||||
|
||||
**List View**
|
||||
|
||||

|
||||
|
||||
<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 Windows**
|
||||
|
||||

|
||||

|
||||
|
||||
**Theme Customization**
|
||||
|
||||

|
||||
|
||||
<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
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机。
|
||||
|
||||
| 深色模式 | 浅色模式 | 列表视图 |
|
||||
|---------|---------|---------|
|
||||
|  |  |  |
|
||||
**深色模式**
|
||||
|
||||

|
||||
|
||||
**浅色模式**
|
||||
|
||||

|
||||
|
||||
**列表视图**
|
||||
|
||||

|
||||
|
||||
<a name="终端"></a>
|
||||
## 终端
|
||||
|
||||
基于 xterm.js 的 WebGL 加速终端,提供流畅、响应迅速的体验。水平或垂直分割工作区,同时监控多个会话。启用广播模式可一次向所有终端发送命令 —— 非常适合批量管理。主题定制面板提供 50+ 配色方案和实时预览、可调节字号以及多种字体选择,包括 JetBrains Mono 和 Fira Code。
|
||||
|
||||
| 分屏窗口 | 主题定制 |
|
||||
|---------|---------|
|
||||
|  |  |
|
||||
**分屏窗口**
|
||||
|
||||

|
||||

|
||||
|
||||
**主题定制**
|
||||
|
||||

|
||||
|
||||
<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
|
||||
|
||||
@@ -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',
|
||||
@@ -345,6 +356,9 @@ const en: Messages = {
|
||||
'pf.view.list': 'List',
|
||||
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
|
||||
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
|
||||
'pf.deleteActive.confirm': 'Stop and Delete',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'New Folder',
|
||||
@@ -431,6 +445,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 +456,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',
|
||||
@@ -675,6 +693,20 @@ const en: Messages = {
|
||||
'cloudSync.s3.forcePathStyle': 'Force path-style URLs (for MinIO/R2, etc.)',
|
||||
'cloudSync.s3.showSecret': 'Show secrets',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, region, bucket, access key, and secret are required.',
|
||||
'cloudSync.smb.title': 'SMB Settings',
|
||||
'cloudSync.smb.desc': 'Connect to an SMB/CIFS file share for encrypted sync.',
|
||||
'cloudSync.smb.share': 'Share Path',
|
||||
'cloudSync.smb.username': 'Username',
|
||||
'cloudSync.smb.password': 'Password',
|
||||
'cloudSync.smb.domain': 'Domain (optional)',
|
||||
'cloudSync.smb.domainPlaceholder': 'e.g., WORKGROUP',
|
||||
'cloudSync.smb.port': 'Port (optional)',
|
||||
'cloudSync.smb.showSecret': 'Show password',
|
||||
'cloudSync.smb.validation.share': 'Share path is required.',
|
||||
'cloudSync.smb.validation.port': 'Port must be a number between 1 and 65535.',
|
||||
'cloudSync.connect.smb.success': 'SMB connected successfully',
|
||||
'cloudSync.connect.smb.failedTitle': 'SMB connection failed',
|
||||
'cloudSync.provider.smb': 'SMB Share',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV connected successfully',
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
|
||||
'cloudSync.connect.s3.success': 'S3 connected successfully',
|
||||
|
||||
@@ -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': '选择协议',
|
||||
@@ -515,6 +530,20 @@ const zhCN: Messages = {
|
||||
'cloudSync.s3.forcePathStyle': '强制使用 path-style URL(适用于 MinIO/R2 等)',
|
||||
'cloudSync.s3.showSecret': '显示密钥',
|
||||
'cloudSync.s3.validation.required': '端点、Region、Bucket、Access Key 与 Secret 必填。',
|
||||
'cloudSync.smb.title': 'SMB 设置',
|
||||
'cloudSync.smb.desc': '连接到 SMB/CIFS 文件共享以进行加密同步。',
|
||||
'cloudSync.smb.share': '共享路径',
|
||||
'cloudSync.smb.username': '用户名',
|
||||
'cloudSync.smb.password': '密码',
|
||||
'cloudSync.smb.domain': '域(可选)',
|
||||
'cloudSync.smb.domainPlaceholder': '例如:WORKGROUP',
|
||||
'cloudSync.smb.port': '端口(可选)',
|
||||
'cloudSync.smb.showSecret': '显示密码',
|
||||
'cloudSync.smb.validation.share': '共享路径必填。',
|
||||
'cloudSync.smb.validation.port': '端口必须是 1 到 65535 之间的数字。',
|
||||
'cloudSync.connect.smb.success': 'SMB 已连接',
|
||||
'cloudSync.connect.smb.failedTitle': 'SMB 连接失败',
|
||||
'cloudSync.provider.smb': 'SMB 共享',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV 已连接',
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV 连接失败',
|
||||
'cloudSync.connect.s3.success': 'S3 已连接',
|
||||
@@ -634,6 +663,9 @@ const zhCN: Messages = {
|
||||
'pf.view.list': '列表',
|
||||
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.deleteActive.title': '删除正在运行的端口转发?',
|
||||
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本地',
|
||||
|
||||
@@ -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, kdf: state.masterKeyConfig.kdf })
|
||||
: 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 ==========
|
||||
|
||||
|
||||
265
application/state/useUpdateCheck.ts
Normal file
265
application/state/useUpdateCheck.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1045,13 +1045,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="github"
|
||||
name="GitHub Gist"
|
||||
icon={<Github size={24} />}
|
||||
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
|
||||
isSyncing={sync.providers.github.status === 'syncing'}
|
||||
isConnecting={sync.providers.github.status === 'connecting'}
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
|
||||
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
|
||||
isSyncing={sync.providers.github.status === 'syncing'}
|
||||
isConnecting={sync.providers.github.status === 'connecting'}
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
@@ -1061,13 +1061,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="google"
|
||||
name="Google Drive"
|
||||
icon={<GoogleDriveIcon className="w-6 h-6" />}
|
||||
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
|
||||
isSyncing={sync.providers.google.status === 'syncing'}
|
||||
isConnecting={sync.providers.google.status === 'connecting'}
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
|
||||
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
|
||||
isSyncing={sync.providers.google.status === 'syncing'}
|
||||
isConnecting={sync.providers.google.status === 'connecting'}
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
|
||||
onConnect={handleConnectGoogle}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
onSync={() => handleSync('google')}
|
||||
@@ -1077,13 +1077,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="onedrive"
|
||||
name="Microsoft OneDrive"
|
||||
icon={<OneDriveIcon className="w-6 h-6" />}
|
||||
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
|
||||
isSyncing={sync.providers.onedrive.status === 'syncing'}
|
||||
isConnecting={sync.providers.onedrive.status === 'connecting'}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
|
||||
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
|
||||
isSyncing={sync.providers.onedrive.status === 'syncing'}
|
||||
isConnecting={sync.providers.onedrive.status === 'connecting'}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
onSync={() => handleSync('onedrive')}
|
||||
@@ -1093,13 +1093,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="webdav"
|
||||
name={t('cloudSync.provider.webdav')}
|
||||
icon={<Server size={24} />}
|
||||
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
|
||||
isSyncing={sync.providers.webdav.status === 'syncing'}
|
||||
isConnecting={sync.providers.webdav.status === 'connecting'}
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
|
||||
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
|
||||
isSyncing={sync.providers.webdav.status === 'syncing'}
|
||||
isConnecting={sync.providers.webdav.status === 'connecting'}
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
|
||||
onEdit={openWebdavDialog}
|
||||
onConnect={openWebdavDialog}
|
||||
onDisconnect={() => sync.disconnectProvider('webdav')}
|
||||
@@ -1110,13 +1110,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="s3"
|
||||
name={t('cloudSync.provider.s3')}
|
||||
icon={<Database size={24} />}
|
||||
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
|
||||
isSyncing={sync.providers.s3.status === 'syncing'}
|
||||
isConnecting={sync.providers.s3.status === 'connecting'}
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
|
||||
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
|
||||
isSyncing={sync.providers.s3.status === 'syncing'}
|
||||
isConnecting={sync.providers.s3.status === 'connecting'}
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
|
||||
onEdit={openS3Dialog}
|
||||
onConnect={openS3Dialog}
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
@@ -1714,7 +1714,7 @@ interface CloudSyncSettingsProps {
|
||||
|
||||
export const CloudSyncSettings: React.FC<CloudSyncSettingsProps> = (props) => {
|
||||
const { securityState } = useCloudSync();
|
||||
|
||||
|
||||
// Simplified UX: once a master key is configured, we auto-unlock via safeStorage
|
||||
// so users don't have to manage a separate LOCKED screen.
|
||||
if (securityState === 'NO_KEY') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Globe,
|
||||
@@ -26,6 +27,14 @@ import {
|
||||
AsidePanelFooter,
|
||||
} from "./ui/aside-panel";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { SortDropdown } from "./ui/sort-dropdown";
|
||||
@@ -53,6 +62,7 @@ type WizardStep =
|
||||
interface PortForwardingProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
customGroups: string[];
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
@@ -62,6 +72,7 @@ interface PortForwardingProps {
|
||||
const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities = [],
|
||||
customGroups: _customGroups,
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
@@ -205,6 +216,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
// New forwarding menu
|
||||
const [showNewMenu, setShowNewMenu] = useState(false);
|
||||
|
||||
// Delete confirmation dialog state for active tunnels
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [ruleToDelete, setRuleToDelete] = useState<PortForwardingRule | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Reset wizard
|
||||
const resetWizard = () => {
|
||||
setWizardStep("type");
|
||||
@@ -356,12 +372,50 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
};
|
||||
|
||||
// Close edit panel
|
||||
const closeEditPanel = () => {
|
||||
const closeEditPanel = useCallback(() => {
|
||||
setShowEditPanel(false);
|
||||
setEditingRule(null);
|
||||
setEditDraft({});
|
||||
setSelectedRuleId(null);
|
||||
};
|
||||
}, [setSelectedRuleId]);
|
||||
|
||||
// Handle delete with confirmation for active tunnels
|
||||
const handleDeleteRule = useCallback(
|
||||
(rule: PortForwardingRule) => {
|
||||
// If tunnel is active or connecting, show confirmation dialog
|
||||
if (rule.status === "active" || rule.status === "connecting") {
|
||||
setRuleToDelete(rule);
|
||||
setShowDeleteConfirm(true);
|
||||
} else {
|
||||
// If inactive, delete directly
|
||||
if (editingRule?.id === rule.id) {
|
||||
closeEditPanel();
|
||||
}
|
||||
deleteRule(rule.id);
|
||||
}
|
||||
},
|
||||
[editingRule, deleteRule, closeEditPanel],
|
||||
);
|
||||
|
||||
// Confirm delete of active tunnel: stop first, then delete
|
||||
const confirmDeleteActiveRule = useCallback(async () => {
|
||||
if (!ruleToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// Stop the tunnel first
|
||||
await stopTunnel(ruleToDelete.id);
|
||||
// Then delete the rule
|
||||
if (editingRule?.id === ruleToDelete.id) {
|
||||
closeEditPanel();
|
||||
}
|
||||
deleteRule(ruleToDelete.id);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
setRuleToDelete(null);
|
||||
}
|
||||
}, [ruleToDelete, stopTunnel, deleteRule, editingRule, closeEditPanel]);
|
||||
|
||||
// Handle wizard navigation
|
||||
// Flow for local: type -> local-config -> destination -> host-selection
|
||||
@@ -652,12 +706,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
}}
|
||||
onEdit={() => startEditRule(rule)}
|
||||
onDuplicate={() => duplicateRule(rule.id)}
|
||||
onDelete={() => {
|
||||
if (editingRule?.id === rule.id) {
|
||||
closeEditPanel();
|
||||
}
|
||||
deleteRule(rule.id);
|
||||
}}
|
||||
onDelete={() => handleDeleteRule(rule)}
|
||||
onStart={() => handleStartTunnel(rule)}
|
||||
onStop={() => handleStopTunnel(rule)}
|
||||
/>
|
||||
@@ -683,10 +732,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
duplicateRule(editingRule.id);
|
||||
closeEditPanel();
|
||||
}}
|
||||
onDelete={() => {
|
||||
deleteRule(editingRule.id);
|
||||
closeEditPanel();
|
||||
}}
|
||||
onDelete={() => handleDeleteRule(editingRule)}
|
||||
onOpenHostSelector={() => setShowHostSelector(true)}
|
||||
/>
|
||||
)}
|
||||
@@ -796,9 +842,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
onBack={() => setShowHostSelector(false)}
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={_onCreateGroup}
|
||||
title="Select Host"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -817,6 +863,45 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
isValid={isNewFormValid()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Active Tunnel Confirmation Dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => {
|
||||
if (!isDeleting) {
|
||||
setShowDeleteConfirm(open);
|
||||
if (!open) setRuleToDelete(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
{t("pf.deleteActive.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setRuleToDelete(null);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteActiveRule}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("pf.deleteActive.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -743,14 +743,14 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
return true;
|
||||
} catch (e) {
|
||||
setUploadTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === taskId
|
||||
prev.map((task) =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...t,
|
||||
...task,
|
||||
status: "failed" as const,
|
||||
error: e instanceof Error ? e.message : t("sftp.error.uploadFailed"),
|
||||
}
|
||||
: t,
|
||||
: task,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -44,10 +44,13 @@ export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
|
||||
).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [hosts, hostSearch]);
|
||||
const sideLabel = side === 'left' ? t('common.left') : t('common.right');
|
||||
const items = useMemo(() => {
|
||||
return [{ type: 'local' as const, id: 'local' }].concat(
|
||||
filteredHosts.map((host) => ({ type: 'host' as const, id: host.id, host }))
|
||||
);
|
||||
|
||||
type PickerItem = { type: 'local'; id: string } | { type: 'host'; id: string; host: Host };
|
||||
|
||||
const items = useMemo<PickerItem[]>(() => {
|
||||
const localItem: PickerItem = { type: 'local', id: 'local' };
|
||||
const hostItems: PickerItem[] = filteredHosts.map((host) => ({ type: 'host', id: host.id, host }));
|
||||
return [localItem, ...hostItems];
|
||||
}, [filteredHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,7 +65,7 @@ export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
|
||||
setSelectedIndex(0);
|
||||
}, [hostSearch, open]);
|
||||
|
||||
const handleSelect = (item: typeof items[number]) => {
|
||||
const handleSelect = (item: PickerItem) => {
|
||||
if (item.type === 'local') {
|
||||
onSelectLocal();
|
||||
} else {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -345,6 +345,9 @@ export interface TerminalSettings {
|
||||
// Keyboard
|
||||
altAsMeta: boolean; // Use ⌥ as the Meta key
|
||||
scrollOnInput: boolean; // Scroll terminal to bottom on input
|
||||
scrollOnOutput: boolean; // Scroll terminal to bottom on output
|
||||
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
|
||||
scrollOnPaste: boolean; // Scroll terminal to bottom on paste
|
||||
|
||||
// Mouse
|
||||
rightClickBehavior: RightClickBehavior;
|
||||
@@ -381,6 +384,9 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
minimumContrastRatio: 1,
|
||||
altAsMeta: false,
|
||||
scrollOnInput: true,
|
||||
scrollOnOutput: false,
|
||||
scrollOnKeyPress: false,
|
||||
scrollOnPaste: true,
|
||||
rightClickBehavior: 'context-menu',
|
||||
copyOnSelect: false,
|
||||
middleClickPaste: true,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -373,6 +373,7 @@ export const SYNC_STORAGE_KEYS = {
|
||||
PROVIDER_ONEDRIVE: 'netcatty_provider_onedrive_v1',
|
||||
PROVIDER_WEBDAV: 'netcatty_provider_webdav_v1',
|
||||
PROVIDER_S3: 'netcatty_provider_s3_v1',
|
||||
PROVIDER_SMB: 'netcatty_provider_smb_v1',
|
||||
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -6,12 +6,22 @@
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const pty = require("node-pty");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
let electronModule = null;
|
||||
|
||||
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
|
||||
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
|
||||
|
||||
const getLoginShellArgs = (shellPath) => {
|
||||
if (!shellPath || process.platform === "win32") return [];
|
||||
const shellName = path.basename(shellPath);
|
||||
return LOGIN_SHELLS.has(shellName) ? ["-l"] : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the terminal bridge with dependencies
|
||||
*/
|
||||
@@ -52,6 +62,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,14 +99,15 @@ function startLocalSession(event, payload) {
|
||||
? findExecutable("powershell") || "powershell.exe"
|
||||
: process.env.SHELL || "/bin/bash";
|
||||
const shell = payload?.shell || defaultShell;
|
||||
const env = {
|
||||
const shellArgs = getLoginShellArgs(shell);
|
||||
const env = applyLocaleDefaults({
|
||||
...process.env,
|
||||
...(payload?.env || {}),
|
||||
TERM: "xterm-256color",
|
||||
COLORTERM: "truecolor",
|
||||
};
|
||||
});
|
||||
|
||||
const proc = pty.spawn(shell, [], {
|
||||
const proc = pty.spawn(shell, shellArgs, {
|
||||
cols: payload?.cols || 80,
|
||||
rows: payload?.rows || 24,
|
||||
env,
|
||||
|
||||
10
global.d.ts
vendored
10
global.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import type { RemoteFile } from "./types";
|
||||
import type { S3Config, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
|
||||
declare global {
|
||||
// Proxy configuration for SSH connections
|
||||
@@ -261,6 +261,14 @@ interface NetcattyBridge {
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncSmbUpload?(
|
||||
config: SMBConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
|
||||
|
||||
// Port Forwarding
|
||||
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<{
|
||||
|
||||
166
infrastructure/services/updateService.ts
Normal file
166
infrastructure/services/updateService.ts
Normal 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;
|
||||
}
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "netcatty",
|
||||
"version": "0.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.956.0",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
@@ -986,7 +987,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -2063,6 +2063,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -2084,6 +2085,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -2100,6 +2102,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -2114,6 +2117,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -5702,7 +5706,6 @@
|
||||
"integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
@@ -5732,7 +5735,6 @@
|
||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
@@ -6011,8 +6013,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
@@ -6027,7 +6028,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6087,7 +6087,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6487,7 +6486,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7156,7 +7154,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7397,7 +7396,6 @@
|
||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.0.12",
|
||||
"builder-util": "26.0.11",
|
||||
@@ -7715,6 +7713,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7735,6 +7734,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7959,7 +7959,6 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10425,7 +10424,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10484,6 +10482,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10501,6 +10500,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10598,7 +10598,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10608,7 +10607,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11458,6 +11456,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -11521,6 +11520,7 @@
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -11533,6 +11533,7 @@
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
@@ -11554,6 +11555,7 @@
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -11567,6 +11569,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -11581,6 +11584,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -11729,7 +11733,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11906,7 +11909,6 @@
|
||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12342,7 +12344,6 @@
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 785 KiB After Width: | Height: | Size: 875 KiB |
Reference in New Issue
Block a user