Копирование папок desktop и desktop-angular из ветки main
24
.gitignore
vendored
@@ -1,8 +1,31 @@
|
|||||||
.old
|
.old
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/bin
|
||||||
|
**/target
|
||||||
|
**/target/**/*
|
||||||
|
**/target/**/.*
|
||||||
|
**/dist/**/*
|
||||||
|
**/dist/**/.*
|
||||||
|
**/bin/**/*
|
||||||
|
**/bin/**/.*
|
||||||
ui/node_modules
|
ui/node_modules
|
||||||
|
desktop/node_modules
|
||||||
|
desktop-angular/node_modules
|
||||||
|
desktop-angular/bin
|
||||||
ui/dist
|
ui/dist
|
||||||
|
desktop/dist
|
||||||
|
desktop-angular/dist
|
||||||
|
desktop/bin
|
||||||
|
desktop/bin/**/*
|
||||||
|
desktop/bin/**/.*
|
||||||
|
desktop/dist/**/*
|
||||||
|
desktop/dist/**/.*
|
||||||
ui/.angular
|
ui/.angular
|
||||||
ui/.vscode
|
ui/.vscode
|
||||||
|
rust-knocker/target
|
||||||
|
rust-knocker/target/**/*
|
||||||
|
rust-knocker/target/**/.*
|
||||||
back/cmd/public
|
back/cmd/public
|
||||||
back/knocker-serve
|
back/knocker-serve
|
||||||
back/cmd/knocker-serve
|
back/cmd/knocker-serve
|
||||||
@@ -11,3 +34,4 @@ back/cmd/knocker-serve.exe.sha256
|
|||||||
back/cmd/knocker-serve.exe.sha256.txt
|
back/cmd/knocker-serve.exe.sha256.txt
|
||||||
back/cmd/knocker-serve.exe.sha256.txt.sha256
|
back/cmd/knocker-serve.exe.sha256.txt.sha256
|
||||||
back/cmd/knocker-serve.exe.sha256.txt.sha256.txt
|
back/cmd/knocker-serve.exe.sha256.txt.sha256.txt
|
||||||
|
|
||||||
|
|||||||
230
desktop-angular/CUSTOM_FILE_DIALOG.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Custom File Dialogs Implementation
|
||||||
|
|
||||||
|
This document describes the custom file dialog implementations for the desktop-angular project. Both dialogs are native Electron BrowserWindows that provide file browsing and saving capabilities with customizable styling and filtering.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The custom file dialog replaces the system file dialog with a custom implementation that:
|
||||||
|
|
||||||
|
- Opens in a specific directory (configs folder)
|
||||||
|
- Supports file filtering by extension
|
||||||
|
- Has customizable colors and styling
|
||||||
|
- Provides a file browser interface
|
||||||
|
- Returns the same data structure as the system dialog
|
||||||
|
|
||||||
|
## Files Added
|
||||||
|
|
||||||
|
### `src/main/open-dialog.html`
|
||||||
|
|
||||||
|
**Purpose**: HTML/CSS/JS interface for the file open dialog
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- File browser with directory navigation
|
||||||
|
- File filtering by extension
|
||||||
|
- Customizable colors via CSS variables
|
||||||
|
- Double-click to open files
|
||||||
|
- Path input with browse functionality
|
||||||
|
- File icons based on extension
|
||||||
|
- File size display
|
||||||
|
- **File preview** - Shows first 500 characters of selected files
|
||||||
|
|
||||||
|
### `src/main/save-dialog.html`
|
||||||
|
|
||||||
|
**Purpose**: HTML/CSS/JS interface for the file save dialog
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- File name input with validation
|
||||||
|
- Directory path selection
|
||||||
|
- **Files list** - Shows all files in current directory (no filtering)
|
||||||
|
- File type filtering
|
||||||
|
- Customizable colors via CSS variables
|
||||||
|
- Filename validation (invalid characters, reserved names)
|
||||||
|
- **Browse button** - Opens system directory picker
|
||||||
|
|
||||||
|
### `src/main/main.js` (Updated)
|
||||||
|
|
||||||
|
**New Function**: `openCustomFileDialog(config)`
|
||||||
|
|
||||||
|
- Creates a modal BrowserWindow (800x600)
|
||||||
|
- Loads the open-dialog.html interface
|
||||||
|
- Handles IPC communication with the dialog
|
||||||
|
- Returns file selection results
|
||||||
|
|
||||||
|
**New IPC Handler**: `dialog:customFile`
|
||||||
|
|
||||||
|
- Accepts configuration for the dialog
|
||||||
|
- Returns file selection result
|
||||||
|
|
||||||
|
## API Usage
|
||||||
|
|
||||||
|
### From Angular Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await this.ipc.showCustomFileDialog({
|
||||||
|
title: "Open Configuration File",
|
||||||
|
defaultPath: "/path/to/configs",
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "YAML Files",
|
||||||
|
extensions: ["yaml", "yml", "txt"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "All Files",
|
||||||
|
extensions: ["*"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
background: "#aa1c3a",
|
||||||
|
text: "#ffffff",
|
||||||
|
buttonBg: "#ffffff",
|
||||||
|
buttonText: "#aa1c3a",
|
||||||
|
border: "rgba(255,255,255,0.2)"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled) {
|
||||||
|
console.log("Selected file:", result.filePath);
|
||||||
|
console.log("File content:", result.content);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FileDialogConfig {
|
||||||
|
title?: string; // Dialog title
|
||||||
|
defaultPath?: string; // Initial directory path
|
||||||
|
filters?: Array<{ // File type filters
|
||||||
|
name: string; // Filter name (e.g., "YAML Files")
|
||||||
|
extensions: string[]; // File extensions (e.g., ["yaml", "yml"])
|
||||||
|
}>;
|
||||||
|
colors?: { // Custom colors
|
||||||
|
background?: string; // Dialog background
|
||||||
|
text?: string; // Text color
|
||||||
|
buttonBg?: string; // Button background
|
||||||
|
buttonText?: string; // Button text color
|
||||||
|
border?: string; // Border color
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Result Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FileDialogResult {
|
||||||
|
canceled: boolean; // True if dialog was canceled
|
||||||
|
filePath?: string; // Selected file path (if not canceled)
|
||||||
|
content?: string; // File content (if not canceled)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### File Browser
|
||||||
|
|
||||||
|
- **Directory Navigation**: Click folders to navigate
|
||||||
|
- **File Selection**: Click files to select, double-click to open
|
||||||
|
- **Path Input**: Type path manually and press Enter
|
||||||
|
- **Browse Button**: Alternative way to navigate directories
|
||||||
|
|
||||||
|
### File Filtering
|
||||||
|
|
||||||
|
- **Extension-based**: Filters files by extension
|
||||||
|
- **Multiple Filters**: Support for multiple filter groups
|
||||||
|
- **Wildcard Support**: Use "*" for all files
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- **CSS Variables**: Customizable via CSS custom properties
|
||||||
|
- **Theme Matching**: Default colors match app footer theme
|
||||||
|
- **Responsive**: Adapts to window resizing
|
||||||
|
|
||||||
|
### File Icons
|
||||||
|
|
||||||
|
- **Extension-based**: Different icons for different file types
|
||||||
|
- **Fallback**: Default document icon for unknown types
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
### Current Usage
|
||||||
|
|
||||||
|
The custom file dialog is automatically used by the `file:open` IPC handler:
|
||||||
|
|
||||||
|
- Opens in the configs directory
|
||||||
|
- Filters for YAML/encrypted/text files
|
||||||
|
- Uses app theme colors
|
||||||
|
|
||||||
|
### Manual Usage
|
||||||
|
|
||||||
|
You can also use it directly:
|
||||||
|
```typescript
|
||||||
|
// From any Angular component
|
||||||
|
const result = await this.ipc.showCustomFileDialog({
|
||||||
|
title: "Select Image",
|
||||||
|
defaultPath: os.homedir(),
|
||||||
|
filters: [
|
||||||
|
{ name: "Images", extensions: ["png", "jpg", "gif"] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advantages Over System Dialog
|
||||||
|
|
||||||
|
1. **Guaranteed Path**: Always opens in the correct directory
|
||||||
|
2. **Custom Styling**: Matches application theme
|
||||||
|
3. **File Content**: Returns file content automatically
|
||||||
|
4. **Cross-platform**: Consistent behavior across platforms
|
||||||
|
5. **Customizable**: Easy to modify appearance and behavior
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/
|
||||||
|
├── open-dialog.html # Custom file dialog interface
|
||||||
|
├── main.js # Updated with dialog functions
|
||||||
|
└── preload.js # Updated with API exposure
|
||||||
|
|
||||||
|
src/frontend/src/app/
|
||||||
|
└── ipc.service.ts # Updated with dialog wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Window Configuration
|
||||||
|
|
||||||
|
- **Size**: 800x600 pixels (resizable)
|
||||||
|
- **Modal**: Always on top of parent window
|
||||||
|
- **Frameless**: Custom window chrome
|
||||||
|
- **Node Integration**: Enabled for file system access
|
||||||
|
|
||||||
|
### File System Access
|
||||||
|
|
||||||
|
- **Read Directory**: Lists files and folders
|
||||||
|
- **Read Files**: Loads file content as UTF-8
|
||||||
|
- **Path Validation**: Checks if paths exist and are accessible
|
||||||
|
- **Error Handling**: Graceful handling of permission errors
|
||||||
|
|
||||||
|
### IPC Communication
|
||||||
|
|
||||||
|
- **Config Channel**: `file-dialog:config` - sends dialog configuration
|
||||||
|
- **Result Channel**: `file-dialog:result` - returns selection result
|
||||||
|
- **Directory Picker**: `dialog:showDirectoryPicker` - system directory selection
|
||||||
|
- **Async/Await**: Promise-based API for easy integration
|
||||||
|
|
||||||
|
## Recent Updates
|
||||||
|
|
||||||
|
### File Open Dialog
|
||||||
|
|
||||||
|
- **File Preview**: Added preview section showing first 500 characters of selected files
|
||||||
|
- **Smart Preview**: Only shows preview for text files under 1MB
|
||||||
|
- **Preview Styling**: Monospace font with scrollable content area
|
||||||
|
|
||||||
|
### File Save Dialog
|
||||||
|
|
||||||
|
- **Files List**: Replaced content preview with list of files in current directory
|
||||||
|
- **All Files Shown**: Shows all files without filtering by extension
|
||||||
|
- **Browse Button**: Now opens system directory picker dialog
|
||||||
|
- **Directory Navigation**: Click directories in file list to navigate
|
||||||
|
- **File Information**: Shows file size and type icons
|
||||||
|
|
||||||
|
This implementation provides a reliable, customizable file dialog that integrates seamlessly with the application's design and functionality.
|
||||||
154
desktop-angular/ELECTRON_NATIVE_MODALS.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Electron-native Modals Guide
|
||||||
|
|
||||||
|
This guide documents the Electron-native modal dialogs implemented in this app. These modals are opened from the Electron main process (via a small HTML dialog window) and are configurable at runtime (text, buttons, and colors).
|
||||||
|
|
||||||
|
## Why Electron-native modals?
|
||||||
|
|
||||||
|
- Work even if the Angular UI is busy/frozen
|
||||||
|
- True application-level modal (owned by main process)
|
||||||
|
- One-shot dialogs you can call from anywhere in renderer through IPC
|
||||||
|
- Per-dialog runtime theming (background/text/button colors)
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- `src/main/main.js`
|
||||||
|
- IPC handler `dialog:custom`
|
||||||
|
- Helper `openCustomModalWindow(config)` that creates a modal `BrowserWindow`
|
||||||
|
- `src/main/modal.html`
|
||||||
|
- Self-contained dialog UI (HTML/CSS/JS)
|
||||||
|
- Listens for `custom-modal:config` and renders content/buttons
|
||||||
|
- Sends result back via `custom-modal:result`
|
||||||
|
- `src/preload/preload.js`
|
||||||
|
- Exposes `showNativeModal(config)` to `window.api`
|
||||||
|
- `src/frontend/src/app/ipc.service.ts`
|
||||||
|
- Adds `showNativeModal(config)` convenience wrapper for Angular code
|
||||||
|
|
||||||
|
## Runtime API
|
||||||
|
|
||||||
|
Call from Angular (renderer):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const result = await this.ipc.showNativeModal({
|
||||||
|
title: 'Confirm Deletion',
|
||||||
|
message: 'Are you sure you want to delete the item?',
|
||||||
|
buttons: [
|
||||||
|
{ id: 'cancel', label: 'Cancel', style: 'secondary' },
|
||||||
|
{ id: 'delete', label: 'Delete', style: 'danger' }
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
background: '#aa1c3a',
|
||||||
|
text: '#ffffff',
|
||||||
|
buttonBg: '#ffffff',
|
||||||
|
buttonText: '#aa1c3a',
|
||||||
|
secondaryBg: 'rgba(255,255,255,0.1)',
|
||||||
|
secondaryText: '#ffffff'
|
||||||
|
},
|
||||||
|
buttonStyles: {
|
||||||
|
delete: { bg: '#e53935', text: '#fff' },
|
||||||
|
cancel: { bg: 'rgba(255,255,255,0.1)', text: '#ffffff' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// result => { buttonId: string, buttonIndex: number, buttonLabel?: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config Schema
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface NativeModalButton {
|
||||||
|
id: string; // identifier returned as buttonId
|
||||||
|
label: string; // text on the button
|
||||||
|
style?: 'primary' | 'secondary' | 'danger'; // default visual style
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativeModalConfig {
|
||||||
|
title?: string; // dialog title
|
||||||
|
message?: string; // main message (plain text)
|
||||||
|
buttons?: NativeModalButton[]; // 1..3 buttons (extra are ignored)
|
||||||
|
colors?: { // global color overrides
|
||||||
|
background?: string; // dialog background
|
||||||
|
text?: string; // title/message text color
|
||||||
|
buttonBg?: string; // primary button bg
|
||||||
|
buttonText?: string; // primary button text color
|
||||||
|
secondaryBg?: string; // secondary button bg
|
||||||
|
secondaryText?: string; // secondary button text color
|
||||||
|
};
|
||||||
|
buttonStyles?: { // per-button overrides by button id
|
||||||
|
[buttonId: string]: {
|
||||||
|
bg?: string; // background + border color
|
||||||
|
text?: string; // text color
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Color Model
|
||||||
|
|
||||||
|
The dialog uses CSS variables in `modal.html` with sensible defaults matching the app footer theme:
|
||||||
|
|
||||||
|
- `--bg` (default `#aa1c3a`)
|
||||||
|
- `--text` (default `#ffffff`)
|
||||||
|
- `--btn-bg` / `--btn-text` (primary buttons)
|
||||||
|
- `--btn-sec-bg` / `--btn-sec-text` (secondary buttons)
|
||||||
|
|
||||||
|
You can override them per-dialog via `colors` and refine specific buttons through `buttonStyles`.
|
||||||
|
|
||||||
|
## Button Styles
|
||||||
|
|
||||||
|
- `primary`: white background with red text (matches app footer theme)
|
||||||
|
- `secondary`: translucent white background with white text
|
||||||
|
- `danger`: red background (`#e53935`) with white text
|
||||||
|
|
||||||
|
You can still override any of these via `buttonStyles` per button.
|
||||||
|
|
||||||
|
## Result Contract
|
||||||
|
|
||||||
|
The renderer receives:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ buttonId: string, buttonIndex: number, buttonLabel?: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
If the dialog window is closed without a click, you’ll get `{ buttonId: 'closed', buttonIndex: -1 }`.
|
||||||
|
|
||||||
|
## How it Works (Flow)
|
||||||
|
|
||||||
|
1. Renderer calls `window.api.showNativeModal(config)` (exposed by preload)
|
||||||
|
2. Main process handles `dialog:custom`, opens a modal `BrowserWindow` and loads `modal.html`
|
||||||
|
3. After the page loads, main sends `custom-modal:config` with the payload
|
||||||
|
4. The page renders content and buttons; on click it emits `custom-modal:result`
|
||||||
|
5. Main resolves the original IPC with `{ buttonId, buttonIndex, buttonLabel }`
|
||||||
|
|
||||||
|
## Example: Yes/No/Cancel With Custom Colors
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await this.ipc.showNativeModal({
|
||||||
|
title: 'Save Changes',
|
||||||
|
message: 'Save before closing?',
|
||||||
|
buttons: [
|
||||||
|
{ id: 'yes', label: 'Yes', style: 'primary' },
|
||||||
|
{ id: 'no', label: 'No', style: 'secondary' },
|
||||||
|
{ id: 'cancel', label: 'Cancel', style: 'danger' }
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
background: '#1e293b',
|
||||||
|
text: '#e2e8f0',
|
||||||
|
buttonBg: '#e2e8f0',
|
||||||
|
buttonText: '#1e293b',
|
||||||
|
secondaryBg: 'rgba(226,232,240,0.12)',
|
||||||
|
secondaryText: '#e2e8f0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes & Considerations
|
||||||
|
|
||||||
|
- Maximum 3 buttons are rendered (extra are ignored)
|
||||||
|
- Message is plain text (no HTML injection)
|
||||||
|
- The dialog is frameless and always-on-top, sized 560x340 by default
|
||||||
|
- Parent is the currently focused window, when available
|
||||||
|
- Closing the dialog without a click returns `{ buttonId: 'closed' }`
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
- Keep Angular modal for in-app UX consistency and speed
|
||||||
|
- Add Electron-native modal for cases where UI thread may be busy, or when deep theming and app-level modality are desired
|
||||||
297
desktop-angular/HOW_TO.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# Пошаговый гайд: как “поднять” существующий Angular-проект из `ui/` внутри нового Electron-проекта `desktop-angular/` (без копирования собранного кода)
|
||||||
|
|
||||||
|
Ниже — практичный сценарий: вы создаёте папку `desktop-angular/` с каркасом Electron, подключаете туда исходники Angular (те, что уже лежат в `ui/`), настраиваете общий запуск в dev-режиме и сборку prod-пакета. Никаких переносов уже собранных файлов, всё работает от исходников Angular.
|
||||||
|
|
||||||
|
## Что получится в итоге
|
||||||
|
|
||||||
|
- Dev-режим: одновременно запускается `ng serve` из `ui/` и Electron, который открывает `http://localhost:4200`.
|
||||||
|
- Prod-режим: сначала билдится Angular в `ui/dist/...`, затем Electron упаковывает приложение и грузит `index.html` из собранного Angular.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Создаём новый проект `desktop-angular/` для Electron
|
||||||
|
|
||||||
|
- В корне репозитория создайте папку `desktop-angular/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir /home/su/projects/articles/embed-gui-article/desktop-angular
|
||||||
|
cd /home/su/projects/articles/embed-gui-article/desktop-angular
|
||||||
|
npm init -y
|
||||||
|
```
|
||||||
|
|
||||||
|
- Устанавливаем зависимости Electron и утилиты для дев-сценария:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -D electron electron-builder concurrently wait-on cross-env
|
||||||
|
```
|
||||||
|
|
||||||
|
Рекомендуемая структура (минимум):
|
||||||
|
|
||||||
|
- `desktop-angular/package.json` — скрипты для dev/prod.
|
||||||
|
- `desktop-angular/src/main/main.js` — основной процесс Electron.
|
||||||
|
- `desktop-angular/src/preload/preload.js` — безопасный мост.
|
||||||
|
- (Без `renderer/` — рендерером будет ваш Angular из `ui/`.)
|
||||||
|
|
||||||
|
Создайте папки и файлы:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p src/main src/preload
|
||||||
|
touch src/main/main.js src/preload/preload.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Подключаемся к уже существующему Angular-проекту в `ui/`
|
||||||
|
|
||||||
|
Предполагаем, что Angular уже установлен и запускается из `/home/su/projects/articles/embed-gui-article/ui/` стандартными командами:
|
||||||
|
|
||||||
|
- Dev: `npm start` (или `ng serve`)
|
||||||
|
- Build: `npm run build`
|
||||||
|
|
||||||
|
Если нет `npm start`, добавьте его в `ui/package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"start": "ng serve --port 4200 --disable-host-check",
|
||||||
|
"build": "ng build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Код `main.js` для Electron (dev: URL, prod: файл из dist)
|
||||||
|
|
||||||
|
Создайте простой `BrowserWindow`, который:
|
||||||
|
|
||||||
|
- в dev грузит `http://localhost:4200`
|
||||||
|
- в prod грузит файл `../ui/dist/project-front/browser/index.html`
|
||||||
|
|
||||||
|
Обратите внимание на относительные пути: мы опираемся на реальную структуру вашего репо и текущие выходные пути Angular (`ui/dist/project-front/browser` у вас уже есть).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// desktop-angular/src/main/main.js
|
||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
win.on('ready-to-show', () => win.show());
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
win.loadURL('http://localhost:4200');
|
||||||
|
// win.webContents.openDevTools(); // включите по желанию
|
||||||
|
} else {
|
||||||
|
// В PROD грузим из собранного Angular
|
||||||
|
const indexPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../../ui/dist/project-front/browser/index.html'
|
||||||
|
);
|
||||||
|
win.loadFile(indexPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Код `preload.js` (минимальная безопасная заготовка)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// desktop-angular/src/preload/preload.js
|
||||||
|
const { contextBridge } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('env', {
|
||||||
|
isElectron: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
В Angular вы сможете проверять наличие `window.env?.isElectron`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Скрипты в `desktop-angular/package.json`
|
||||||
|
|
||||||
|
Добавим удобные команды:
|
||||||
|
|
||||||
|
- `dev`: параллельно запускает Angular dev-сервер в `ui/` и Electron (ждём порт 4200).
|
||||||
|
- `build:ui`: сборка Angular.
|
||||||
|
- `start`: старт Electron в prod-режиме (если нужно локально проверить загрузку собранного Angular без упаковки).
|
||||||
|
- `dist`: упаковка Electron (electron-builder).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "desktop-angular",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/main/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -k -n UI,ELECTRON -c green,cyan \"cd ../ui && npm start\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development electron .\"",
|
||||||
|
"build:ui": "cd ../ui && npm run build",
|
||||||
|
"start": "cross-env NODE_ENV=production electron .",
|
||||||
|
"dist": "npm run build:ui && cross-env NODE_ENV=production electron-builder"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.0.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^31.0.0",
|
||||||
|
"electron-builder": "^24.13.3",
|
||||||
|
"wait-on": "^7.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Конфигурация упаковки Electron (electron-builder)
|
||||||
|
|
||||||
|
Добавьте секцию `build` в `desktop-angular/package.json`. Мы не копируем Angular внутрь `desktop-angular` — Electron будет брать собранный Angular прямо из `ui/dist/...` при упаковке (через `extraResources`). Это удобно и прозрачно.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"appId": "com.yourcompany.knocker",
|
||||||
|
"productName": "Knocker Desktop Angular",
|
||||||
|
"files": [
|
||||||
|
"src/main/**/*",
|
||||||
|
"src/preload/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "../ui/dist/project-front/browser",
|
||||||
|
"to": "ui-dist",
|
||||||
|
"filter": ["**/*"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"linux": {
|
||||||
|
"target": ["AppImage"],
|
||||||
|
"category": "Utility",
|
||||||
|
"artifactName": "Knocker-Desktop-Angular-${version}.${ext}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
И соответствующее изменение в `main.js` на prod, если хотите грузить из `resources/ui-dist` у упакованного приложения:
|
||||||
|
|
||||||
|
- В упакованном `.AppImage` ваши ресурсы оказываются в `process.resourcesPath`.
|
||||||
|
- Тогда путь до `index.html` будет: `path.join(process.resourcesPath, 'ui-dist', 'index.html')`.
|
||||||
|
|
||||||
|
Адаптированный prod-фрагмент:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ... в main.js, в ветке !isDev:
|
||||||
|
const indexPath = app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, 'ui-dist', 'index.html')
|
||||||
|
: path.resolve(__dirname, '../../../ui/dist/project-front/browser/index.html');
|
||||||
|
|
||||||
|
win.loadFile(indexPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
Так вы покроете оба случая: локальный prod-запуск и реальный упакованный билд.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) CORS, безопасность и доступ к файлам
|
||||||
|
|
||||||
|
- В dev Angular грузится по `http://localhost:4200` — обычно проблем нет.
|
||||||
|
- В prod Angular грузится с `file://` — убедитесь, что никакие запросы не завязаны на абсолютные HTTP-URL без необходимости.
|
||||||
|
- По умолчанию оставляем `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`. Для доступа к нативному коду используйте IPC и `preload.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Локальный запуск dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/su/projects/articles/embed-gui-article/desktop-angular
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- Скрипт поднимет `ng serve` из `ui/` и откроет Electron, который загрузит `http://localhost:4200`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Локальная проверка prod без упаковки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Собираем Angular
|
||||||
|
cd /home/su/projects/articles/embed-gui-article/ui
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Запускаем Electron в prod-режиме
|
||||||
|
cd /home/su/projects/articles/embed-gui-article/desktop-angular
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Упаковка приложения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/su/projects/articles/embed-gui-article/desktop-angular
|
||||||
|
npm run dist
|
||||||
|
```
|
||||||
|
|
||||||
|
- Angular соберётся.
|
||||||
|
- Electron-упаковщик положит содержимое `ui/dist/project-front/browser` в `resources/ui-dist`.
|
||||||
|
- Приложение загрузит `index.html` из `resources/ui-dist`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) Советы по интеграции Angular и Electron
|
||||||
|
|
||||||
|
- Детект среды в Angular:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// пример: app.component.ts
|
||||||
|
isElectron = typeof (window as any).env?.isElectron === 'boolean';
|
||||||
|
```
|
||||||
|
|
||||||
|
- Статические пути: В Angular используйте относительные пути к ассетам, чтобы они работали и в `http://localhost:4200`, и в `file://` в prod.
|
||||||
|
- IPC: если потребуется обмен с main-процессом, расширяйте `preload.js` и опишите чёткий API через `contextBridge`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12) Итоговая структура (ключевые узлы)
|
||||||
|
|
||||||
|
- `ui/` — как есть, исходники Angular.
|
||||||
|
- `desktop-angular/`
|
||||||
|
- `package.json` — скрипты `dev`, `build:ui`, `start`, `dist`, секция `build` (electron-builder).
|
||||||
|
- `src/main/main.js` — создаёт окно, грузит URL в dev и файл в prod.
|
||||||
|
- `src/preload/preload.js` — мост в рендерер.
|
||||||
|
- Ничего из `ui/` внутрь `desktop-angular/` мы не копируем; работаем поверх исходников.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что делать, если вы хотите переиспользовать текущую папку `desktop/`?
|
||||||
|
|
||||||
|
Можно; у вас уже есть `desktop/` с Electron. Тогда:
|
||||||
|
|
||||||
|
- Либо перенести логику оттуда в `desktop-angular/` (описано выше).
|
||||||
|
- Либо в существующем `desktop/` заменить рендерер на Angular из `ui/` по тем же принципам: dev — `loadURL('http://localhost:4200')`, prod — `loadFile(...)` на `ui/dist/...`.
|
||||||
284
desktop-angular/MODAL_DIALOG_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Modal Dialog Implementation Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the implementation of two configurable modal dialog systems for the desktop-angular project:
|
||||||
|
|
||||||
|
1) Angular in-app modal component (already integrated into the UI)
|
||||||
|
2) Electron-native modal window (opens a frameless BrowserWindow as a dialog)
|
||||||
|
|
||||||
|
Both allow variable questions, 1-3 buttons with custom labels, and return metadata for the clicked button. The Electron-native dialog additionally supports color customization for background, text, button colors per dialog.
|
||||||
|
|
||||||
|
## Files Added/Modified
|
||||||
|
|
||||||
|
### 1. New Files Created
|
||||||
|
|
||||||
|
#### `src/frontend/src/app/modal.service.ts`
|
||||||
|
|
||||||
|
**Purpose**: Core service for managing modal dialogs
|
||||||
|
**What it does**:
|
||||||
|
|
||||||
|
- Defines interfaces for modal configuration (`ModalConfig`, `ModalButton`, `ModalResult`)
|
||||||
|
- Provides `ModalService` with methods to show/hide modals
|
||||||
|
- Returns promises that resolve with button click results
|
||||||
|
- Includes convenience methods for common dialog types
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
|
||||||
|
- `show(config: ModalConfig): Promise<ModalResult>` - Main method to show custom modals
|
||||||
|
- `showConfirm(title, message): Promise<boolean>` - Yes/No confirmation dialog
|
||||||
|
- `showYesNoCancel(title, message): Promise<'yes'|'no'|'cancel'>` - Three-option dialog
|
||||||
|
- `showInfo(title, message): Promise<void>` - Information dialog with OK button
|
||||||
|
|
||||||
|
#### `src/frontend/src/app/modal.component.ts`
|
||||||
|
|
||||||
|
**Purpose**: Angular component that renders the modal dialog
|
||||||
|
**What it does**:
|
||||||
|
|
||||||
|
- Displays modal overlay and dialog box
|
||||||
|
- Renders configurable buttons with different styles
|
||||||
|
- Handles button clicks and communicates with service
|
||||||
|
- Manages modal visibility through service subscription
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
|
||||||
|
- Standalone Angular component
|
||||||
|
- Reactive to service state changes
|
||||||
|
- Supports button styling (primary, secondary, danger)
|
||||||
|
- Overlay click handling (currently disabled)
|
||||||
|
|
||||||
|
#### `src/frontend/src/app/modal.component.scss`
|
||||||
|
|
||||||
|
**Purpose**: Styling for the modal dialog
|
||||||
|
**What it does**:
|
||||||
|
|
||||||
|
- Creates modal overlay with semi-transparent background
|
||||||
|
- Styles dialog box with footer-matching colors (#aa1c3a)
|
||||||
|
- Implements button styles matching the app's footer buttons
|
||||||
|
- Provides responsive design for mobile devices
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
|
||||||
|
- Modal overlay with backdrop
|
||||||
|
- Dialog box with header, body, and footer sections
|
||||||
|
- Button styles matching app footer (white background, red text)
|
||||||
|
- Three button style variants: primary, secondary, danger
|
||||||
|
- Mobile-responsive design
|
||||||
|
|
||||||
|
### 2. Modified Files
|
||||||
|
|
||||||
|
#### `src/frontend/src/app/root.component.ts`
|
||||||
|
|
||||||
|
**Changes Made**:
|
||||||
|
|
||||||
|
- Added imports for `ModalService` and `ModalComponent`
|
||||||
|
- Added `ModalComponent` to component imports
|
||||||
|
- Injected `ModalService` in constructor
|
||||||
|
- Added example methods demonstrating modal usage:
|
||||||
|
- `showCustomModal()` - Shows 3-button custom dialog
|
||||||
|
- `showConfirmDialog()` - Shows Yes/No confirmation
|
||||||
|
- `showYesNoCancelDialog()` - Shows Yes/No/Cancel dialog
|
||||||
|
- `showInfoDialog()` - Shows information dialog
|
||||||
|
|
||||||
|
#### `src/frontend/src/app/root.component.html`
|
||||||
|
|
||||||
|
**Changes Made**:
|
||||||
|
|
||||||
|
- Added `<app-modal></app-modal>` component to template
|
||||||
|
- Added test buttons in form section to demonstrate modal functionality
|
||||||
|
- Test buttons include: Custom Modal, Confirm Dialog, Yes/No/Cancel, Info Dialog
|
||||||
|
|
||||||
|
### 3. Electron-Native Modal (Alternative)
|
||||||
|
|
||||||
|
To support invoking modals from the Electron main process with fully customizable styling, we added an alternative modal implementation which opens a dedicated `BrowserWindow` as a modal dialog.
|
||||||
|
|
||||||
|
#### New Files
|
||||||
|
|
||||||
|
- `src/main/modal.html`
|
||||||
|
- HTML/CSS/JS for a self-contained modal page
|
||||||
|
- Receives configuration over IPC (`custom-modal:config`)
|
||||||
|
- Renders title, message and up to 3 buttons
|
||||||
|
- Colors can be customized via CSS variables populated from config:
|
||||||
|
- background, text, buttonBg, buttonText, secondaryBg, secondaryText
|
||||||
|
- Each button can define its own inline style overrides via `buttonStyles` map
|
||||||
|
|
||||||
|
#### Changes in `src/main/main.js`
|
||||||
|
|
||||||
|
- Added `openCustomModalWindow(config)` helper to create a modal `BrowserWindow`
|
||||||
|
- Loads `modal.html` and sends the configuration after load
|
||||||
|
- Listens for `custom-modal:result` IPC to resolve the clicked button
|
||||||
|
- Exposed IPC handler `dialog:custom` to open the modal from renderer/preload
|
||||||
|
|
||||||
|
#### Changes in `src/preload/preload.js`
|
||||||
|
|
||||||
|
- Exposed `showNativeModal(config)` in `window.api` via `ipcRenderer.invoke('dialog:custom')`
|
||||||
|
|
||||||
|
#### Changes in `src/frontend/src/app/ipc.service.ts`
|
||||||
|
|
||||||
|
- Added wrapper method `showNativeModal(config)` to call the Electron-native modal from Angular code
|
||||||
|
|
||||||
|
#### Usage Example (from Angular renderer)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const result = await this.ipc.showNativeModal({
|
||||||
|
title: 'System Dialog',
|
||||||
|
message: 'Proceed with operation?',
|
||||||
|
buttons: [
|
||||||
|
{ id: 'yes', label: 'Yes', style: 'primary' },
|
||||||
|
{ id: 'no', label: 'No', style: 'secondary' },
|
||||||
|
{ id: 'cancel', label: 'Cancel', style: 'danger' }
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
background: '#aa1c3a',
|
||||||
|
text: '#ffffff',
|
||||||
|
buttonBg: '#ffffff',
|
||||||
|
buttonText: '#aa1c3a',
|
||||||
|
secondaryBg: 'rgba(255,255,255,0.1)',
|
||||||
|
secondaryText: '#ffffff'
|
||||||
|
},
|
||||||
|
buttonStyles: {
|
||||||
|
yes: { bg: '#ffffff', text: '#aa1c3a' },
|
||||||
|
no: { bg: 'rgba(255,255,255,0.1)', text: '#ffffff' },
|
||||||
|
cancel: { bg: '#e53935', text: '#fff' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// result => { buttonId: 'yes' | 'no' | 'cancel', buttonIndex: number, buttonLabel?: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Custom Modal
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await this.modal.show({
|
||||||
|
title: 'Custom Dialog',
|
||||||
|
message: 'Choose an option:',
|
||||||
|
buttons: [
|
||||||
|
{ id: 'option1', label: 'Option 1', style: 'primary' },
|
||||||
|
{ id: 'option2', label: 'Option 2', style: 'secondary' },
|
||||||
|
{ id: 'cancel', label: 'Cancel', style: 'danger' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
console.log(`Clicked: ${result.buttonLabel} (ID: ${result.buttonId})`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Confirmation Dialog
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const confirmed = await this.modal.showConfirm(
|
||||||
|
'Delete Item',
|
||||||
|
'Are you sure you want to delete this item?'
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
|
// Proceed with deletion
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Yes/No/Cancel Dialog
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await this.modal.showYesNoCancel(
|
||||||
|
'Save Changes',
|
||||||
|
'Do you want to save your changes?'
|
||||||
|
);
|
||||||
|
switch (result) {
|
||||||
|
case 'yes': /* Save and continue */ break;
|
||||||
|
case 'no': /* Continue without saving */ break;
|
||||||
|
case 'cancel': /* Cancel operation */ break;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Information Dialog
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await this.modal.showInfo(
|
||||||
|
'Success',
|
||||||
|
'Your changes have been saved successfully.'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Button Styles
|
||||||
|
|
||||||
|
The modal supports three button styles:
|
||||||
|
|
||||||
|
1. **Primary** (`style: 'primary'`): White background, red text - matches footer buttons
|
||||||
|
2. **Secondary** (`style: 'secondary'`): Transparent background, white text
|
||||||
|
3. **Danger** (`style: 'danger'`): Red background, white text
|
||||||
|
|
||||||
|
## Styling Details
|
||||||
|
|
||||||
|
### Color Scheme
|
||||||
|
|
||||||
|
- **Background**: #aa1c3a (matches footer)
|
||||||
|
- **Text**: White (#ffffff)
|
||||||
|
- **Primary Buttons**: White background, red text
|
||||||
|
- **Secondary Buttons**: Transparent with white text
|
||||||
|
- **Danger Buttons**: Red background (#e53935)
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
- Modal overlay covers entire screen
|
||||||
|
- Dialog is centered and responsive
|
||||||
|
- Maximum width: 500px
|
||||||
|
- Mobile-friendly with stacked buttons on small screens
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Service Integration
|
||||||
|
|
||||||
|
The `ModalService` is provided at root level, making it available throughout the application:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
constructor(private modal: ModalService) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Integration
|
||||||
|
|
||||||
|
The `ModalComponent` is imported in the main component and added to the template:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<app-modal></app-modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The implementation includes test buttons in the form section that demonstrate all modal types:
|
||||||
|
|
||||||
|
- Custom Modal: Shows 3-button dialog with different styles
|
||||||
|
- Confirm Dialog: Shows Yes/No confirmation
|
||||||
|
- Yes/No/Cancel: Shows 3-option dialog
|
||||||
|
- Info Dialog: Shows information with OK button
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements could include:
|
||||||
|
|
||||||
|
1. Modal animations (fade in/out)
|
||||||
|
2. Keyboard navigation support
|
||||||
|
3. Escape key to close
|
||||||
|
4. Custom modal sizes
|
||||||
|
5. Modal stacking support
|
||||||
|
6. Form inputs within modals
|
||||||
|
7. Optional keyboard shortcuts (Enter/Esc)
|
||||||
|
8. Focus management and initial focus button
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
The modal system requires:
|
||||||
|
|
||||||
|
- Angular CommonModule
|
||||||
|
- RxJS for reactive programming
|
||||||
|
- No external dependencies
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
``` еуче
|
||||||
|
src/frontend/src/app/
|
||||||
|
├── modal.service.ts # Service for modal management
|
||||||
|
├── modal.component.ts # Modal component
|
||||||
|
├── modal.component.scss # Modal styles
|
||||||
|
├── root.component.ts # Updated with modal integration
|
||||||
|
└── root.component.html # Updated with modal component
|
||||||
|
```
|
||||||
|
|
||||||
|
This implementation provides a complete, reusable modal dialog system that matches the application's design language and provides flexible configuration options for various dialog types.
|
||||||
4210
desktop-angular/package-lock.json
generated
Normal file
63
desktop-angular/package.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "desktop-angular",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/main/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -k -n UI,ELECTRON -c green,cyan \"cd src/frontend && npm start\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development electron .\"",
|
||||||
|
"devElectron": "concurrently -k -n ELECTRON -c cyan \"cross-env NODE_ENV=development electron .\"",
|
||||||
|
"build:ui": "cd src/frontend && npm run build",
|
||||||
|
"install:ui": "cd src/frontend && npm ci",
|
||||||
|
"start": "cross-env NODE_ENV=production electron .",
|
||||||
|
"go:build": "bash -lc 'mkdir -p bin && cd ../back && go build -o ../desktop-angular/bin/full-go-knocker .'",
|
||||||
|
"dist": "npm run build:ui && npm run go:build && cross-env NODE_ENV=production electron-builder"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"cross-env": "^10.0.0",
|
||||||
|
"electron": "^38.1.2",
|
||||||
|
"electron-builder": "^26.0.12",
|
||||||
|
"wait-on": "^9.0.1"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.yourcompany.knocker",
|
||||||
|
"productName": "Knocker Desktop By Angular",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/main/**/*",
|
||||||
|
"src/preload/**/*",
|
||||||
|
"package.json",
|
||||||
|
"bin/**/*",
|
||||||
|
"node_modules/**/*"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "src/frontend/dist/project-front/browser",
|
||||||
|
"to": "ui-dist",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "bin",
|
||||||
|
"to": "bin",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage"
|
||||||
|
],
|
||||||
|
"category": "Utility",
|
||||||
|
"artifactName": "Knocker-Desktop-Angular-${version}.${ext}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
desktop-angular/src/frontend/.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = 100
|
||||||
|
trim_trailing_whitespace = false
|
||||||
42
desktop-angular/src/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
4
desktop-angular/src/frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
123
desktop-angular/src/frontend/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Angular (Cursor)",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"url": "http://localhost:4200",
|
||||||
|
"webRoot": "${workspaceFolder}",
|
||||||
|
"sourceMaps": true,
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**",
|
||||||
|
"${workspaceFolder}/node_modules/**"
|
||||||
|
],
|
||||||
|
"sourceMapPathOverrides": {
|
||||||
|
"webpack:///./src/*": "${webRoot}/src/*",
|
||||||
|
"webpack:///src/*": "${webRoot}/src/*",
|
||||||
|
"webpack:///*": "*",
|
||||||
|
"webpack:///./~/*": "${webRoot}/node_modules/*",
|
||||||
|
"meteor://💻app/*": "${webRoot}/*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Attach to Chrome (Cursor)",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "attach",
|
||||||
|
"port": 9222,
|
||||||
|
"webRoot": "${workspaceFolder}",
|
||||||
|
"sourceMaps": true,
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**",
|
||||||
|
"${workspaceFolder}/node_modules/**"
|
||||||
|
],
|
||||||
|
"sourceMapPathOverrides": {
|
||||||
|
"webpack:///./src/*": "${webRoot}/src/*",
|
||||||
|
"webpack:///src/*": "${webRoot}/src/*",
|
||||||
|
"webpack:///*": "*",
|
||||||
|
"webpack:///./~/*": "${webRoot}/node_modules/*",
|
||||||
|
"meteor://💻app/*": "${webRoot}/*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug Angular Application",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"url": "http://localhost:4200",
|
||||||
|
"webRoot": "${workspaceFolder}",
|
||||||
|
"sourceMaps": true,
|
||||||
|
"sourceMapPathOverrides": {
|
||||||
|
"webpack:/*": "${webRoot}/*",
|
||||||
|
"/./*": "${webRoot}/*",
|
||||||
|
"/src/*": "${webRoot}/src/*",
|
||||||
|
"/*": "*",
|
||||||
|
"/./~/*": "${webRoot}/node_modules/*"
|
||||||
|
},
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"userDataDir": "${workspaceFolder}/.vscode/chrome-debug-profile",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"--disable-web-security",
|
||||||
|
"--disable-features=VizDisplayCompositor",
|
||||||
|
"--remote-debugging-port=9222"
|
||||||
|
],
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**",
|
||||||
|
"${workspaceFolder}/node_modules/**"
|
||||||
|
],
|
||||||
|
"trace": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug Angular (Simple)",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"url": "http://localhost:4200",
|
||||||
|
"webRoot": "${workspaceFolder}",
|
||||||
|
"sourceMaps": true,
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**",
|
||||||
|
"${workspaceFolder}/node_modules/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Attach to Chrome",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "attach",
|
||||||
|
"port": 9222,
|
||||||
|
"webRoot": "${workspaceFolder}",
|
||||||
|
"sourceMaps": true,
|
||||||
|
"sourceMapPathOverrides": {
|
||||||
|
"webpack:/*": "${webRoot}/*",
|
||||||
|
"/./*": "${webRoot}/*",
|
||||||
|
"/src/*": "${webRoot}/src/*",
|
||||||
|
"/*": "*",
|
||||||
|
"/./~/*": "${webRoot}/node_modules/**"
|
||||||
|
},
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**",
|
||||||
|
"${workspaceFolder}/node_modules/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug with Edge",
|
||||||
|
"type": "msedge",
|
||||||
|
"request": "launch",
|
||||||
|
"url": "http://localhost:4200",
|
||||||
|
"webRoot": "${workspaceFolder}",
|
||||||
|
"sourceMaps": true,
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**",
|
||||||
|
"${workspaceFolder}/node_modules/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ng test",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: test",
|
||||||
|
"url": "http://localhost:9876/debug.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
49
desktop-angular/src/frontend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"djlint.showInstallError": false,
|
||||||
|
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||||
|
"typescript.suggest.autoImports": true,
|
||||||
|
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
|
"source.organizeImports": "explicit"
|
||||||
|
},
|
||||||
|
"debug.javascript.autoAttachFilter": "disabled",
|
||||||
|
"debug.javascript.terminalOptions": {
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**",
|
||||||
|
"${workspaceFolder}/node_modules/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"files.associations": {
|
||||||
|
"*.ts": "typescript",
|
||||||
|
"*.html": "html"
|
||||||
|
},
|
||||||
|
"emmet.includeLanguages": {
|
||||||
|
"typescript": "html"
|
||||||
|
},
|
||||||
|
"debug.allowBreakpointsEverywhere": true,
|
||||||
|
"debug.javascript.breakOnConditionalError": false,
|
||||||
|
"debug.javascript.codelens.npmScripts": "never",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"workbench.colorCustomizations": {
|
||||||
|
"activityBar.activeBackground": "#fa1b49",
|
||||||
|
"activityBar.background": "#fa1b49",
|
||||||
|
"activityBar.foreground": "#e7e7e7",
|
||||||
|
"activityBar.inactiveForeground": "#e7e7e799",
|
||||||
|
"activityBarBadge.background": "#155e02",
|
||||||
|
"activityBarBadge.foreground": "#e7e7e7",
|
||||||
|
"commandCenter.border": "#e7e7e799",
|
||||||
|
"sash.hoverBorder": "#fa1b49",
|
||||||
|
"statusBar.background": "#dd0531",
|
||||||
|
"statusBar.foreground": "#e7e7e7",
|
||||||
|
"statusBarItem.hoverBackground": "#fa1b49",
|
||||||
|
"statusBarItem.remoteBackground": "#dd0531",
|
||||||
|
"statusBarItem.remoteForeground": "#e7e7e7",
|
||||||
|
"titleBar.activeBackground": "#dd0531",
|
||||||
|
"titleBar.activeForeground": "#e7e7e7",
|
||||||
|
"titleBar.inactiveBackground": "#dd053199",
|
||||||
|
"titleBar.inactiveForeground": "#e7e7e799"
|
||||||
|
},
|
||||||
|
"peacock.color": "#dd0531",
|
||||||
|
}
|
||||||
86
desktop-angular/src/frontend/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "npm: start",
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": true,
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "npm: build",
|
||||||
|
"type": "npm",
|
||||||
|
"script": "build",
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "silent",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared"
|
||||||
|
},
|
||||||
|
"problemMatcher": ["$tsc"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "npm: test",
|
||||||
|
"type": "npm",
|
||||||
|
"script": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Start Chrome for Debugging",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "google-chrome",
|
||||||
|
"args": [
|
||||||
|
"--remote-debugging-port=9222",
|
||||||
|
"--user-data-dir=${workspaceFolder}/.vscode/chrome-debug-profile",
|
||||||
|
"--disable-web-security",
|
||||||
|
"--disable-features=VizDisplayCompositor"
|
||||||
|
],
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared"
|
||||||
|
},
|
||||||
|
"group": "build"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
69
desktop-angular/src/frontend/README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# ProjectApp
|
||||||
|
|
||||||
|
Современное веб-приложение для обеспечения GUI в браузере,
|
||||||
|
построенное на Angular 17 с использованием PrimeNG.
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### Установка зависимостей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск в режиме разработки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу `http://localhost:4200/`
|
||||||
|
|
||||||
|
### Сборка для продакшена
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Артефакты сборки будут сохранены в папке `dist/`
|
||||||
|
|
||||||
|
## 🏗️ Архитектура
|
||||||
|
|
||||||
|
- **Frontend**: Angular 17 с PrimeNG 17
|
||||||
|
- **Backend**: Go с Gin (отдельный проект)
|
||||||
|
- **API**: REST API для получения данных о погоде
|
||||||
|
- **Стили**: SCSS с Glassmorphism эффектами
|
||||||
|
|
||||||
|
## 🔧 Разработка
|
||||||
|
|
||||||
|
### Генерация компонентов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit тесты
|
||||||
|
ng test
|
||||||
|
|
||||||
|
# E2E тесты
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Линтинг
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка стиля кода
|
||||||
|
ng lint
|
||||||
|
|
||||||
|
## 📦 Сборка для встраивания
|
||||||
|
|
||||||
|
Для встраивания в Go приложение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:embed [/path/to/front] # /home/user/projects/golang/go-project/project-front
|
||||||
|
```
|
||||||
|
|
||||||
|
Файлы будут собраны в папку `/path/to/front`
|
||||||
101
desktop-angular/src/frontend/angular.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"project-front": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/project-front",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets",
|
||||||
|
"src/manifest.webmanifest"
|
||||||
|
],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1.5mb",
|
||||||
|
"maximumError": "2mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "128kb",
|
||||||
|
"maximumError": "256kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all",
|
||||||
|
"serviceWorker": "ngsw-config.json",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "project-front:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "project-front:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "project-front:build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": ["zone.js", "zone.js/testing"],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets",
|
||||||
|
"src/manifest.webmanifest"
|
||||||
|
],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
desktop-angular/src/frontend/build-for-embeding.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Ошибка: Пожалуйста, укажите директорию назначения."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DESTINATION_DIR=$1
|
||||||
|
|
||||||
|
echo "Building Angular app for embedding..."
|
||||||
|
# ng build --configuration production --output-path ../../golang/gin-restapi/weather-front
|
||||||
|
rm -rf "$DESTINATION_DIR"
|
||||||
|
npx ng build --configuration production
|
||||||
|
|
||||||
|
mkdir -p "$DESTINATION_DIR"
|
||||||
|
|
||||||
|
cp -r /home/su/projects/angular/project-front/dist/project-front/browser/* \
|
||||||
|
"$DESTINATION_DIR"
|
||||||
|
|
||||||
|
echo "Build completed successfully!"
|
||||||
|
echo "Frontend files are ready for embedding in Go binary"
|
||||||
30
desktop-angular/src/frontend/ngsw-config.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||||
|
"index": "/index.html",
|
||||||
|
"assetGroups": [
|
||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"installMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/favicon.ico",
|
||||||
|
"/index.html",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/*.css",
|
||||||
|
"/*.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assets",
|
||||||
|
"installMode": "lazy",
|
||||||
|
"updateMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/assets/**",
|
||||||
|
"/media/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13460
desktop-angular/src/frontend/package-lock.json
generated
Normal file
48
desktop-angular/src/frontend/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "front-project",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"build:embed": "ng build --configuration production --output-path ",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test",
|
||||||
|
"server": "http-server -p 8880 -c-1 dist/front-project/browser"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^17.3.0",
|
||||||
|
"@angular/common": "^17.3.0",
|
||||||
|
"@angular/compiler": "^17.3.0",
|
||||||
|
"@angular/core": "^17.3.0",
|
||||||
|
"@angular/forms": "^17.3.0",
|
||||||
|
"@angular/platform-browser": "^17.3.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||||
|
"@angular/router": "^17.3.0",
|
||||||
|
"@angular/service-worker": "^17.3.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"primeflex": "^3.3.1",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primeng": "^17.18.15",
|
||||||
|
"roboto-fontface": "^0.10.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.14.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^17.3.8",
|
||||||
|
"@angular/cli": "^17.3.8",
|
||||||
|
"@angular/compiler-cli": "^17.3.0",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"http-server": "^14.1.1",
|
||||||
|
"jasmine-core": "~5.1.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"typescript": "~5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
desktop-angular/src/frontend/src/app/app.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApplicationConfig, isDevMode } from '@angular/core';
|
||||||
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { provideServiceWorker } from '@angular/service-worker';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideAnimationsAsync(),
|
||||||
|
provideHttpClient(),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideServiceWorker('ngsw-worker.js', {
|
||||||
|
enabled: !isDevMode(),
|
||||||
|
registrationStrategy: 'registerWhenStable:30000'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
};
|
||||||
7
desktop-angular/src/frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { RootComponent } from './root.component';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: '', component: RootComponent },
|
||||||
|
{ path: '**', redirectTo: '' },
|
||||||
|
];
|
||||||
100
desktop-angular/src/frontend/src/app/ipc.service.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
api?: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class IpcService {
|
||||||
|
private get api() {
|
||||||
|
return (typeof window !== 'undefined' && window.api) ? window.api : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfig(key: string): Promise<any> {
|
||||||
|
return this.api?.getConfig ? this.api.getConfig(key) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setConfig(key: string, value: any): Promise<any> {
|
||||||
|
return this.api?.setConfig ? this.api.setConfig(key, value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllConfig(): Promise<any> {
|
||||||
|
return this.api?.getAllConfig ? this.api.getAllConfig() : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAllConfig(cfg: any): Promise<any> {
|
||||||
|
return this.api?.setAllConfig ? this.api.setAllConfig(cfg) : { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async openFile(): Promise<any> {
|
||||||
|
return this.api?.openFile ? this.api.openFile() : { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAs(payload: any): Promise<any> {
|
||||||
|
return this.api?.saveAs ? this.api.saveAs(payload) : { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSilent(payload: any): Promise<any> {
|
||||||
|
return this.api?.saveSilent ? this.api.saveSilent(payload) : { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async revealInFolder(p: string): Promise<any> {
|
||||||
|
return this.api?.revealInFolder ? this.api.revealInFolder(p) : { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async localKnock(payload: any): Promise<any> {
|
||||||
|
return this.api?.localKnock ? this.api.localKnock(payload) : { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNetworkInterfaces(): Promise<any> {
|
||||||
|
return this.api?.getNetworkInterfaces ? this.api.getNetworkInterfaces() : { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(payload: any): Promise<any> {
|
||||||
|
return this.api?.testConnection ? this.api.testConnection(payload) : { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeSettings(): Promise<any> {
|
||||||
|
return this.api?.closeSettings ? this.api.closeSettings() : { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Electron-native custom modal
|
||||||
|
async showNativeModal(config: any): Promise<{ buttonId: string; buttonIndex: number; buttonLabel?: string }> {
|
||||||
|
return this.api?.showNativeModal ? this.api.showNativeModal(config) : { buttonId: 'unavailable', buttonIndex: -1 } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom file dialog
|
||||||
|
async showCustomFileDialog(config: any): Promise<{ canceled: boolean; filePath?: string; content?: string }> {
|
||||||
|
return this.api?.showCustomFileDialog ? this.api.showCustomFileDialog(config) : { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom save dialog
|
||||||
|
async showCustomSaveDialog(config: any): Promise<{ canceled: boolean; filePath?: string }> {
|
||||||
|
return this.api?.showCustomSaveDialog ? this.api.showCustomSaveDialog(config) : { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config files management
|
||||||
|
async listConfigFiles(): Promise<{files: string[]}> {
|
||||||
|
return this.api?.listConfigFiles ? this.api.listConfigFiles() : {files: []};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadConfigFile(fileName: string): Promise<{success: boolean, content?: string}> {
|
||||||
|
return this.api?.loadConfigFile ? this.api.loadConfigFile(fileName) : {success: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
// App lifecycle
|
||||||
|
async checkUnsavedChanges(): Promise<boolean> {
|
||||||
|
return this.api?.checkUnsavedChanges ? this.api.checkUnsavedChanges() : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAML dirty state sync
|
||||||
|
async setYamlDirty(isDirty: boolean): Promise<void> {
|
||||||
|
if (this.api?.setYamlDirty) {
|
||||||
|
await this.api.setYamlDirty(isDirty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
113
desktop-angular/src/frontend/src/app/knock.service.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import * as yaml from 'js-yaml';
|
||||||
|
import { IpcService } from './ipc.service';
|
||||||
|
|
||||||
|
export interface KnockExecuteBody {
|
||||||
|
targets?: string;
|
||||||
|
delay?: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
waitConnection?: boolean;
|
||||||
|
gateway?: string;
|
||||||
|
config_yaml?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class KnockService {
|
||||||
|
constructor(private http: HttpClient, private ipc: IpcService) {}
|
||||||
|
|
||||||
|
basicAuthHeader(password: string): Record<string, string> {
|
||||||
|
const token = btoa(`knocker:${password || ''}`);
|
||||||
|
return { Authorization: `Basic ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
convertInlineToYaml(targetsStr: string, delay: string, waitConnection: boolean): string {
|
||||||
|
const entries = (targetsStr || '').split(';').filter(Boolean);
|
||||||
|
const config: any = {
|
||||||
|
targets: entries.map(e => {
|
||||||
|
const parts = e.split(':');
|
||||||
|
const protocol = parts[0] || 'tcp';
|
||||||
|
const host = parts[1] || '127.0.0.1';
|
||||||
|
const port = parseInt(parts[2] || '22', 10);
|
||||||
|
return { protocol, host, ports: [port], wait_connection: !!waitConnection };
|
||||||
|
}),
|
||||||
|
delay: delay || '1s'
|
||||||
|
};
|
||||||
|
return yaml.dump(config as any, { lineWidth: 120 });
|
||||||
|
}
|
||||||
|
|
||||||
|
convertYamlToInline(yamlText: string): { targets: string; delay: string; waitConnection: boolean } {
|
||||||
|
if (!yamlText.trim()) return { targets: 'tcp:127.0.0.1:22', delay: '1s', waitConnection: false };
|
||||||
|
const config: any = yaml.load(yamlText) || {};
|
||||||
|
const list: string[] = [];
|
||||||
|
(config.targets || []).forEach((t: any) => {
|
||||||
|
const protocol = t.protocol || 'tcp';
|
||||||
|
const host = t.host || '127.0.0.1';
|
||||||
|
const ports = t.ports || [t.port] || [22];
|
||||||
|
(Array.isArray(ports) ? ports : [ports]).forEach((p: any) => list.push(`${protocol}:${host}:${p}`));
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
targets: list.join(';'),
|
||||||
|
delay: config.delay || '1s',
|
||||||
|
waitConnection: !!config.targets?.[0]?.wait_connection
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
extractPathFromYaml(text: string): string {
|
||||||
|
try {
|
||||||
|
const doc: any = yaml.load(text);
|
||||||
|
if (doc && typeof doc === 'object' && typeof doc.path === 'string') return doc.path;
|
||||||
|
} catch {}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
patchYamlPath(text: string, newPath: string): string {
|
||||||
|
try {
|
||||||
|
const doc: any = text.trim() ? yaml.load(text) : {};
|
||||||
|
if (doc && typeof doc === 'object') {
|
||||||
|
doc.path = newPath || '';
|
||||||
|
return yaml.dump(doc, { lineWidth: 120 });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEncryptedYaml(text: string): boolean {
|
||||||
|
return (text || '').trim().startsWith('ENCRYPTED:');
|
||||||
|
}
|
||||||
|
|
||||||
|
async knockViaHttp(apiBase: string, password: string, body: KnockExecuteBody): Promise<Response> {
|
||||||
|
const headers = { ...this.basicAuthHeader(password), 'Content-Type': 'application/json' };
|
||||||
|
return fetch(`${apiBase}/knock-actions/execute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async encryptYaml(apiBase: string, password: string, content: string) {
|
||||||
|
const headers = { ...this.basicAuthHeader(password), 'Content-Type': 'application/json' };
|
||||||
|
const r = await fetch(`${apiBase}/knock-actions/encrypt`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ yaml: content })
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptYaml(apiBase: string, password: string, encrypted: string) {
|
||||||
|
const headers = { ...this.basicAuthHeader(password), 'Content-Type': 'application/json' };
|
||||||
|
const r = await fetch(`${apiBase}/knock-actions/decrypt`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ encrypted })
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async localKnock(payload: any) {
|
||||||
|
return this.ipc.localKnock(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
133
desktop-angular/src/frontend/src/app/modal.component.scss
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/* Modal overlay - covers entire screen */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal dialog container */
|
||||||
|
.modal-dialog {
|
||||||
|
background: #aa1c3a; /* Same as footer background */
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal header */
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal body */
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px 24px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal footer - styled like the main app footer */
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 24px 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal buttons - styled like footer buttons */
|
||||||
|
.modal-btn {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #aa1c3a;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 80px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn:hover {
|
||||||
|
background: #f8e8ec;
|
||||||
|
border-color: #f8e8ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button style variants */
|
||||||
|
.modal-btn-primary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #aa1c3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-primary:hover {
|
||||||
|
background: #f8e8ec;
|
||||||
|
border-color: #f8e8ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-danger {
|
||||||
|
background: #e53935;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-danger:hover {
|
||||||
|
background: #c62828;
|
||||||
|
border-color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.modal-dialog {
|
||||||
|
width: 95%;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
desktop-angular/src/frontend/src/app/modal.component.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ModalService, ModalConfig, ModalButton } from './modal.service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="modal-overlay" *ngIf="config" (click)="onOverlayClick($event)">
|
||||||
|
<div class="modal-dialog" (click)="$event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title">{{ config.title }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="modal-message">{{ config.message }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
*ngFor="let button of config.buttons"
|
||||||
|
class="modal-btn"
|
||||||
|
[class]="getButtonClass(button)"
|
||||||
|
(click)="onButtonClick(button)"
|
||||||
|
>
|
||||||
|
{{ button.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styleUrls: ['./modal.component.scss']
|
||||||
|
})
|
||||||
|
export class ModalComponent implements OnInit, OnDestroy {
|
||||||
|
config: ModalConfig | null = null;
|
||||||
|
private subscription: Subscription = new Subscription();
|
||||||
|
|
||||||
|
constructor(private modalService: ModalService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.subscription.add(
|
||||||
|
this.modalService.modal$.subscribe(config => {
|
||||||
|
this.config = config;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
onButtonClick(button: ModalButton): void {
|
||||||
|
this.modalService.onButtonClick(button.id, button.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayClick(event: Event): void {
|
||||||
|
// Close modal when clicking overlay (optional behavior)
|
||||||
|
// this.modalService.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
getButtonClass(button: ModalButton): string {
|
||||||
|
const baseClass = 'modal-btn';
|
||||||
|
switch (button.style) {
|
||||||
|
case 'primary':
|
||||||
|
return `${baseClass} modal-btn-primary`;
|
||||||
|
case 'danger':
|
||||||
|
return `${baseClass} modal-btn-danger`;
|
||||||
|
case 'secondary':
|
||||||
|
default:
|
||||||
|
return `${baseClass} modal-btn-secondary`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
desktop-angular/src/frontend/src/app/modal.service.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface ModalButton {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
style?: 'primary' | 'secondary' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalConfig {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
buttons: ModalButton[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalResult {
|
||||||
|
buttonId: string;
|
||||||
|
buttonLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ModalService {
|
||||||
|
private modalSubject = new BehaviorSubject<ModalConfig | null>(null);
|
||||||
|
private resultSubject = new BehaviorSubject<ModalResult | null>(null);
|
||||||
|
|
||||||
|
public modal$: Observable<ModalConfig | null> = this.modalSubject.asObservable();
|
||||||
|
public result$: Observable<ModalResult | null> = this.resultSubject.asObservable();
|
||||||
|
|
||||||
|
show(config: ModalConfig): Promise<ModalResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.modalSubject.next(config);
|
||||||
|
|
||||||
|
const subscription = this.result$.subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
this.hide();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {
|
||||||
|
this.modalSubject.next(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onButtonClick(buttonId: string, buttonLabel: string): void {
|
||||||
|
this.resultSubject.next({ buttonId, buttonLabel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods for common dialog types
|
||||||
|
async showConfirm(title: string, message: string): Promise<boolean> {
|
||||||
|
const result = await this.show({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
buttons: [
|
||||||
|
{ id: 'cancel', label: 'Cancel', style: 'secondary' },
|
||||||
|
{ id: 'confirm', label: 'Confirm', style: 'primary' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return result.buttonId === 'confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
async showYesNoCancel(title: string, message: string): Promise<'yes' | 'no' | 'cancel'> {
|
||||||
|
const result = await this.show({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
buttons: [
|
||||||
|
{ id: 'yes', label: 'Yes', style: 'primary' },
|
||||||
|
{ id: 'no', label: 'No', style: 'secondary' },
|
||||||
|
{ id: 'cancel', label: 'Cancel', style: 'secondary' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return result.buttonId as 'yes' | 'no' | 'cancel';
|
||||||
|
}
|
||||||
|
|
||||||
|
async showInfo(title: string, message: string): Promise<void> {
|
||||||
|
await this.show({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
buttons: [
|
||||||
|
{ id: 'ok', label: 'OK', style: 'primary' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
233
desktop-angular/src/frontend/src/app/root.component.html
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<!-- Synced with desktop/src/renderer/index.html structure -->
|
||||||
|
<div class="app">
|
||||||
|
<header style="background-color: #aa1c3a; color: #fff;">
|
||||||
|
<h1 style="font-size: 2.5rem; margin-bottom: 1rem; ">
|
||||||
|
Port Knocker - Desktop (powered by Angular)
|
||||||
|
</h1>
|
||||||
|
<div class="modes">
|
||||||
|
<label
|
||||||
|
><input
|
||||||
|
type="radio"
|
||||||
|
name="mode"
|
||||||
|
value="inline"
|
||||||
|
[checked]="mode === 'inline'"
|
||||||
|
(change)="setMode('inline')"
|
||||||
|
/>
|
||||||
|
Inline</label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
><input
|
||||||
|
type="radio"
|
||||||
|
name="mode"
|
||||||
|
value="yaml"
|
||||||
|
[checked]="mode === 'yaml'"
|
||||||
|
(change)="setMode('yaml')"
|
||||||
|
/>
|
||||||
|
YAML</label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
><input
|
||||||
|
type="radio"
|
||||||
|
name="mode"
|
||||||
|
value="form"
|
||||||
|
[checked]="mode === 'form'"
|
||||||
|
(change)="setMode('form')"
|
||||||
|
/>
|
||||||
|
Form</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="constant-section" class="constant-mode-section">
|
||||||
|
<div class="row">
|
||||||
|
<label style="min-width: 100px">Api URL</label>
|
||||||
|
<input
|
||||||
|
id="apiUrl"
|
||||||
|
type="text"
|
||||||
|
placeholder="Введите api url"
|
||||||
|
[(ngModel)]="apiBase"
|
||||||
|
(change)="onApiUrlChange()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label style="min-width: 100px">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
[(ngModel)]="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label style="min-width: 100px">Delay</label>
|
||||||
|
<input
|
||||||
|
id="delay"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="delay"
|
||||||
|
(change)="onDelayChange()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="inline-section"
|
||||||
|
class="mode-section"
|
||||||
|
[class.hidden]="mode !== 'inline'"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<label style="min-width: 100px">Targets</label>
|
||||||
|
<input
|
||||||
|
id="targets"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="targets"
|
||||||
|
(change)="onTargetsChange()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label style="min-width: 100px">Gateway: </label>
|
||||||
|
<input
|
||||||
|
id="gateway"
|
||||||
|
type="text"
|
||||||
|
placeholder="optional"
|
||||||
|
[(ngModel)]="gateway"
|
||||||
|
(change)="onGatewayChange()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top: 1rem">
|
||||||
|
<label
|
||||||
|
><input id="verbose" type="checkbox" [(ngModel)]="verbose" /> Verbose</label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
><input
|
||||||
|
id="waitConnection"
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="waitConnection"
|
||||||
|
/>
|
||||||
|
Wait connection</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="yaml-section"
|
||||||
|
class="mode-section"
|
||||||
|
[class.hidden]="mode !== 'yaml'"
|
||||||
|
>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn-primary" (click)="onNewConfiguration()">New Configuration</button>
|
||||||
|
<button class="btn-warning" (click)="onOpenFile()">Open file</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
[ngClass]="yamlDirty ? 'btn-primary' : 'btn-secondary'"
|
||||||
|
(click)="onSaveCurrent()"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button class="btn-success" (click)="onSaveFile()">Save file as</button>
|
||||||
|
<select class="config-select"
|
||||||
|
[(ngModel)]="selectedConfigFile"
|
||||||
|
(ngModelChange)="onConfigFileSelect($event)"
|
||||||
|
[ngModelOptions]="{standalone: true}">
|
||||||
|
<option value="">Select config file...</option>
|
||||||
|
<option *ngFor="let file of configFiles" [value]="file">{{ file }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="yaml-editor-container">
|
||||||
|
<textarea
|
||||||
|
id="configYAML"
|
||||||
|
placeholder="Paste YAML or open file"
|
||||||
|
[(ngModel)]="configYAML"
|
||||||
|
(ngModelChange)="onConfigYamlChange($event)"
|
||||||
|
[class.has-unsaved-changes]="yamlDirty"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="form-section"
|
||||||
|
class="mode-section"
|
||||||
|
[class.hidden]="mode !== 'form'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="targetsList"
|
||||||
|
style="display: flex; flex-direction: column; gap: 0.5rem"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
*ngFor="let t of formTargets; let i = index"
|
||||||
|
class="row form-target-row"
|
||||||
|
>
|
||||||
|
<select [(ngModel)]="t.proto" class="target-proto">
|
||||||
|
<option value="tcp">tcp</option>
|
||||||
|
<option value="udp">udp</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="host"
|
||||||
|
[(ngModel)]="t.host"
|
||||||
|
class="target-host"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="port"
|
||||||
|
[(ngModel)]="t.port"
|
||||||
|
class="target-port"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="gateway (optional)"
|
||||||
|
[(ngModel)]="t.gateway"
|
||||||
|
class="target-gateway"
|
||||||
|
/>
|
||||||
|
<button class="btn-danger" (click)="removeFormTarget(i)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top: 0.5rem">
|
||||||
|
<button id="addTarget" class="btn-primary" (click)="addFormTarget()">
|
||||||
|
Add target
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Test Buttons -->
|
||||||
|
<div class="row" *ngIf="false" style="margin-top: 1rem; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button class="btn-primary" (click)="showCustomModal()">Custom Modal</button>
|
||||||
|
<button class="btn-primary" (click)="showConfirmDialog()">Confirm Dialog</button>
|
||||||
|
<button class="btn-primary" (click)="showYesNoCancelDialog()">Yes/No/Cancel</button>
|
||||||
|
<button class="btn-primary" (click)="showInfoDialog()">Info Dialog</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer style="background-color: #aa1c3a; color: #fff;">
|
||||||
|
<div class="row" style="width: 100%; margin-top: 1rem">
|
||||||
|
<button class="footer-btn" style="font-size: 1.5rem; width: 100%" (click)="onExecute()">Execute</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
[class.hidden]="mode !== 'yaml'"
|
||||||
|
id="encrypt-decrypt-row"
|
||||||
|
style="width: 100%; margin-top: 1rem"
|
||||||
|
>
|
||||||
|
<button class="footer-btn" style="width: 50%" (click)="onEncrypt()">Encrypt</button>
|
||||||
|
<button class="footer-btn" style="width: 50%" (click)="onDecrypt()">Decrypt</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="width: 100%; margin-top: 1rem">
|
||||||
|
<span
|
||||||
|
[class.errorStatus]="
|
||||||
|
status.toLowerCase().includes('error') ||
|
||||||
|
status.toLowerCase().includes('ошибка')
|
||||||
|
"
|
||||||
|
[class.successStatus]="
|
||||||
|
status.toLowerCase().includes('success') ||
|
||||||
|
status.toLowerCase().includes('успех')
|
||||||
|
"
|
||||||
|
id="status"
|
||||||
|
>{{ status }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Modal Dialog Component -->
|
||||||
|
<app-modal></app-modal>
|
||||||
|
</div>
|
||||||
293
desktop-angular/src/frontend/src/app/root.component.scss
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
ul {
|
||||||
|
list-style-type: none; /* Remove default list styling */
|
||||||
|
padding: 0; /* Remove default padding */
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
cursor: pointer; /* Change cursor to pointer on hover */
|
||||||
|
padding: 10px; /* Add some padding for better click area */
|
||||||
|
transition: background-color 0.3s; /* Smooth transition for background color */
|
||||||
|
}
|
||||||
|
|
||||||
|
li:hover {
|
||||||
|
color: #9a5d5d; /* Change background color on hover */
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: larger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-container {
|
||||||
|
margin-top: 30px;
|
||||||
|
display: flex; /* Use Flexbox */
|
||||||
|
flex-direction: column; /* Stack children vertically */
|
||||||
|
align-items: center; /* Center horizontally */
|
||||||
|
// justify-content: center; /* Center vertically */
|
||||||
|
// height: 100vh; /* Full viewport height */
|
||||||
|
text-align: center; /* Center text */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unified control look for form fields (IP, protocol, port, etc.) */
|
||||||
|
.row input[type="text"],
|
||||||
|
.row input[type="number"],
|
||||||
|
.row input[type="password"],
|
||||||
|
.row select {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #c7c7c7;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #222;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row input[type="text"]:focus,
|
||||||
|
.row input[type="number"]:focus,
|
||||||
|
.row input[type="password"]:focus,
|
||||||
|
.row select:focus {
|
||||||
|
border-color: #1976d2;
|
||||||
|
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Align items nicely inside rows */
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make each form target row occupy full width and distribute fields */
|
||||||
|
.form-target-row {
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-target-row .target-proto {
|
||||||
|
flex: 0 0 92px; /* select width */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-target-row .target-host {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-target-row .target-port {
|
||||||
|
flex: 0 0 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-target-row .target-gateway {
|
||||||
|
flex: 0 1 260px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-target-row .btn-danger {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e53935;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #e53935;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #c62828;
|
||||||
|
border-color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary (blue) button */
|
||||||
|
.btn-primary {
|
||||||
|
background: #1976d2;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #1976d2;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #1565c0;
|
||||||
|
border-color: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary button style */
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #6c757d;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
border-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success button style */
|
||||||
|
.btn-success {
|
||||||
|
background: #28a745;
|
||||||
|
color: #0a0a0a;
|
||||||
|
border: 1px solid #28a745;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background: #218838;
|
||||||
|
border-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #0a0a0a;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover {
|
||||||
|
background: #ff9800;
|
||||||
|
border-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config file select dropdown */
|
||||||
|
.config-select {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 12px;
|
||||||
|
min-width: 200px;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #aa1c3a;
|
||||||
|
box-shadow: 0 0 0 2px rgba(170, 28, 58, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer buttons matching footer background */
|
||||||
|
.footer-btn {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #aa1c3a;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn:hover {
|
||||||
|
background: #f8e8ec;
|
||||||
|
border-color: #f8e8ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorStatus {
|
||||||
|
color: #e53935 !important;
|
||||||
|
background-color: #ffe6e6 !important;
|
||||||
|
border: 1px solid #e53935 !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
margin-top: 10px !important;
|
||||||
|
animation: shake 0.3s ease-in-out 0s 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-5px); }
|
||||||
|
50% { transform: translateX(5px); }
|
||||||
|
75% { transform: translateX(-5px); }
|
||||||
|
100% { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.successStatus {
|
||||||
|
color: #2e7d32!important;
|
||||||
|
background-color: #e6ffe6!important;
|
||||||
|
border: 1px solid #2e7d32!important;
|
||||||
|
border-radius: 4px!important;
|
||||||
|
padding: 8px 12px!important;
|
||||||
|
font-weight: bold!important;
|
||||||
|
font-size: 14px!important;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)!important;
|
||||||
|
display: inline-block!important;
|
||||||
|
margin-top: 10px!important;
|
||||||
|
animation: shake 0.3s ease-in-out 0s 1!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-5px); }
|
||||||
|
50% { transform: translateX(5px); }
|
||||||
|
75% { transform: translateX(-5px); }
|
||||||
|
100% { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* YAML Editor Status Indicator */
|
||||||
|
.yaml-editor-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #856404;
|
||||||
|
animation: fadeIn 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-status.unsaved {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border-color: #f5c6cb;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #dc3545;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.has-unsaved-changes {
|
||||||
|
border-color: #dc3545;
|
||||||
|
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.15);
|
||||||
|
background-color: #fff5f5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
767
desktop-angular/src/frontend/src/app/root.component.ts
Normal file
@@ -0,0 +1,767 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, OnInit, ChangeDetectorRef, NgZone } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { IpcService } from './ipc.service';
|
||||||
|
import { KnockService } from './knock.service';
|
||||||
|
import { ModalService } from './modal.service';
|
||||||
|
import { ModalComponent } from './modal.component';
|
||||||
|
import * as yaml from 'js-yaml';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ModalComponent],
|
||||||
|
templateUrl: './root.component.html',
|
||||||
|
styleUrls: ['./root.component.scss'],
|
||||||
|
})
|
||||||
|
export class RootComponent implements OnInit {
|
||||||
|
mode: 'inline' | 'yaml' | 'form' = 'inline';
|
||||||
|
apiBase = 'http://localhost:8080/api/v1';
|
||||||
|
password = '';
|
||||||
|
delay = '1s';
|
||||||
|
targets = 'tcp:127.0.0.1:22';
|
||||||
|
gateway = '';
|
||||||
|
verbose = true;
|
||||||
|
waitConnection = false;
|
||||||
|
configYAML = '';
|
||||||
|
status = '';
|
||||||
|
formTargets: {
|
||||||
|
proto: 'tcp' | 'udp';
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
gateway?: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
// Config files management
|
||||||
|
configFiles: string[] = [];
|
||||||
|
selectedConfigFile: string = '';
|
||||||
|
previousSelectedConfigFile: string = '';
|
||||||
|
yamlDirty: boolean = false;
|
||||||
|
private suppressYamlDirty: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ipc: IpcService,
|
||||||
|
private knock: KnockService,
|
||||||
|
private modal: ModalService,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
private ngZone: NgZone
|
||||||
|
) {
|
||||||
|
this.loadConfigFiles('', '');
|
||||||
|
|
||||||
|
// Add beforeunload event listener to prevent closing with unsaved changes
|
||||||
|
// if (typeof window !== 'undefined') {
|
||||||
|
// window.addEventListener('beforeunload', (e: BeforeUnloadEvent) => {
|
||||||
|
// console.log('beforeunload event triggered, yamlDirty:', this.yamlDirty);
|
||||||
|
|
||||||
|
// if (this.yamlDirty) {
|
||||||
|
// console.log('Preventing close due to unsaved changes');
|
||||||
|
// this.showCloseConfirmationDialog().then((result: any) => {
|
||||||
|
// if (result === 'save') {
|
||||||
|
// console.log('Saving changes');
|
||||||
|
// this.onSaveCurrent().then((result: any) => {
|
||||||
|
// if (result) {
|
||||||
|
// console.log('Changes saved');
|
||||||
|
// } else {
|
||||||
|
// console.log('Changes not saved');
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// } else if (result === 'discard') {
|
||||||
|
// return undefined;
|
||||||
|
// } else {
|
||||||
|
// return undefined;
|
||||||
|
// }
|
||||||
|
// }).catch((error: any) => {
|
||||||
|
// console.error('Error showing close confirmation dialog:', error);
|
||||||
|
// return undefined;
|
||||||
|
// });
|
||||||
|
|
||||||
|
// e.preventDefault();
|
||||||
|
// e.returnValue = 'У вас есть несохранённые изменения. Вы уверены, что хотите покинуть страницу?'; // Chrome requires returnValue to be set
|
||||||
|
// return 'У вас есть несохранённые изменения. Вы уверены, что хотите покинуть страницу?'; // For older browsers
|
||||||
|
// }
|
||||||
|
// return undefined;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadConfigFiles(fileName?: string, configsPath?: string) {
|
||||||
|
try {
|
||||||
|
const result = await this.ipc.listConfigFiles();
|
||||||
|
this.configFiles = result.files || [];
|
||||||
|
// console.log('Config files loaded:', this.configFiles);
|
||||||
|
|
||||||
|
if (fileName) {
|
||||||
|
// Принудительно обновляем DOM и устанавливаем selectedConfigFile
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
this.selectedConfigFile = fileName;
|
||||||
|
// console.log('Setting selectedConfigFile to:', fileName);
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
this.previousSelectedConfigFile = fileName;
|
||||||
|
await this.setYamlDirty(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading config files:', error);
|
||||||
|
this.configFiles = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onConfigYamlChange(_: string) {
|
||||||
|
if (this.suppressYamlDirty) return;
|
||||||
|
console.log('YAML content changed, setting yamlDirty = true');
|
||||||
|
await this.setYamlDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to check unsaved changes (called via IPC)
|
||||||
|
checkUnsavedChanges(): boolean {
|
||||||
|
console.log('checkUnsavedChanges called via IPC, yamlDirty:', this.yamlDirty);
|
||||||
|
return this.yamlDirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to update yamlDirty state
|
||||||
|
private async setYamlDirty(value: boolean) {
|
||||||
|
this.yamlDirty = value;
|
||||||
|
(window as any).yamlDirty = value;
|
||||||
|
|
||||||
|
// Sync with main process
|
||||||
|
try {
|
||||||
|
await this.ipc.setYamlDirty(value);
|
||||||
|
console.log('YAML dirty state synced with main process:', value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing yamlDirty state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate YAML content
|
||||||
|
private validateYaml(yamlContent: string): { isValid: boolean; error?: string } {
|
||||||
|
if (!yamlContent || yamlContent.trim() === '') {
|
||||||
|
return { isValid: false, error: 'YAML контент не может быть пустым' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
yaml.load(yamlContent);
|
||||||
|
return { isValid: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: `Ошибка в YAML: ${error.message || 'Неизвестная ошибка'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show confirmation dialog for closing with unsaved changes
|
||||||
|
async showCloseConfirmationDialog(): Promise<'save' | 'discard' | 'cancel'> {
|
||||||
|
const result = await this.modal.showYesNoCancel(
|
||||||
|
'Несохранённые изменения',
|
||||||
|
'У вас есть несохранённые изменения в конфигурации. Хотите сохранить?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === 'yes') {
|
||||||
|
return 'save';
|
||||||
|
} else if (result === 'no') {
|
||||||
|
return 'discard';
|
||||||
|
} else {
|
||||||
|
return 'cancel';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onConfigFileSelect(fileName: string) {
|
||||||
|
if (!fileName) {
|
||||||
|
this.selectedConfigFile = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revertSelection = () => {
|
||||||
|
// вернуть обратно визуально
|
||||||
|
const prev = this.previousSelectedConfigFile || '';
|
||||||
|
if (this.selectedConfigFile !== prev) {
|
||||||
|
this.selectedConfigFile = prev;
|
||||||
|
}
|
||||||
|
// Для надёжности: небольшой таймер в зоне Angular
|
||||||
|
this.ngZone.run(() =>
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.selectedConfigFile !== prev) this.selectedConfigFile = prev;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}, 50)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.yamlDirty) {
|
||||||
|
const choice = await this.modal.showYesNoCancel(
|
||||||
|
'Unsaved changes',
|
||||||
|
'You have unsaved changes. Save them before switching configuration?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (choice === 'yes') {
|
||||||
|
const beforeSave =
|
||||||
|
this.previousSelectedConfigFile || this.selectedConfigFile;
|
||||||
|
// Silent save to current file (if known) without dialogs
|
||||||
|
const currentName =
|
||||||
|
this.previousSelectedConfigFile || this.selectedConfigFile || '';
|
||||||
|
let savedOk = false;
|
||||||
|
if (currentName) {
|
||||||
|
const r = await this.ipc.saveSilent({
|
||||||
|
fileName: currentName,
|
||||||
|
content: this.configYAML,
|
||||||
|
});
|
||||||
|
savedOk = !!(r && r.canceled === false && r.filePath);
|
||||||
|
} else {
|
||||||
|
// Fallback: use regular Save As
|
||||||
|
savedOk = await this.onSaveFile();
|
||||||
|
}
|
||||||
|
// onSaveFile may early return; proceed only if not canceled
|
||||||
|
if (!savedOk) {
|
||||||
|
revertSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// После сохранения переключаемся
|
||||||
|
try {
|
||||||
|
const result = await this.ipc.loadConfigFile(fileName);
|
||||||
|
if (result.success && result.content) {
|
||||||
|
this.configYAML = result.content;
|
||||||
|
this.selectedConfigFile = fileName;
|
||||||
|
this.previousSelectedConfigFile = fileName;
|
||||||
|
await this.setYamlDirty(false);
|
||||||
|
await this.loadConfigFiles(fileName);
|
||||||
|
} else {
|
||||||
|
await this.modal.showInfo(
|
||||||
|
'Error',
|
||||||
|
`Failed to load file: ${fileName}`
|
||||||
|
);
|
||||||
|
// вернуть предыдущее значение
|
||||||
|
this.selectedConfigFile = beforeSave;
|
||||||
|
this.previousSelectedConfigFile = beforeSave;
|
||||||
|
console.log(
|
||||||
|
'Revert selection - selectedConfigFile:',
|
||||||
|
this.selectedConfigFile
|
||||||
|
);
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading config file:', error);
|
||||||
|
await this.modal.showInfo('Error', `Error loading file: ${fileName}`);
|
||||||
|
revertSelection();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no или cancel -> вернёмся к прежнему значению и выйдем
|
||||||
|
revertSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не было изменений — просто загружаем
|
||||||
|
try {
|
||||||
|
const result = await this.ipc.loadConfigFile(fileName);
|
||||||
|
if (result.success && result.content) {
|
||||||
|
this.suppressYamlDirty = true;
|
||||||
|
this.configYAML = result.content;
|
||||||
|
this.suppressYamlDirty = false;
|
||||||
|
this.selectedConfigFile = fileName;
|
||||||
|
this.previousSelectedConfigFile = fileName;
|
||||||
|
await this.setYamlDirty(false);
|
||||||
|
await this.loadConfigFiles(fileName);
|
||||||
|
} else {
|
||||||
|
await this.modal.showInfo('Error', `Failed to load file: ${fileName}`);
|
||||||
|
revertSelection();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading config file:', error);
|
||||||
|
await this.modal.showInfo('Error', `Error loading file: ${fileName}`);
|
||||||
|
revertSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.ipc
|
||||||
|
.getConfig('apiBase')
|
||||||
|
.then((v) => {
|
||||||
|
if (typeof v === 'string' && v.trim()) this.apiBase = v;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
this.ipc
|
||||||
|
.getConfig('gateway')
|
||||||
|
.then((v) => {
|
||||||
|
if (typeof v === 'string') this.gateway = v;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
this.ipc
|
||||||
|
.getConfig('inlineTargets')
|
||||||
|
.then((v) => {
|
||||||
|
if (typeof v === 'string') this.targets = v;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
this.ipc
|
||||||
|
.getConfig('delay')
|
||||||
|
.then((v) => {
|
||||||
|
if (typeof v === 'string') this.delay = v;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(m: 'inline' | 'yaml' | 'form') {
|
||||||
|
this.mode = m;
|
||||||
|
}
|
||||||
|
|
||||||
|
addFormTarget() {
|
||||||
|
this.formTargets.push({
|
||||||
|
proto: 'tcp',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 22,
|
||||||
|
gateway: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFormTarget(idx: number) {
|
||||||
|
if (idx < 0 || idx >= this.formTargets.length) return;
|
||||||
|
|
||||||
|
const confirmDeletion = await this.modal.showConfirm(
|
||||||
|
'Confirm Deletion',
|
||||||
|
'Are you sure you want to delete this target?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmDeletion) return;
|
||||||
|
|
||||||
|
this.formTargets.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildInlineFromForm(): string {
|
||||||
|
return this.formTargets
|
||||||
|
.map((t) => `${t.proto}:${(t.host || '').trim()}:${Number(t.port) || 0}`)
|
||||||
|
.filter((s) => /^(tcp|udp):[^:]+:\d+$/.test(s))
|
||||||
|
.join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onApiUrlChange() {
|
||||||
|
if (!this.apiBase?.trim()) return;
|
||||||
|
try {
|
||||||
|
await this.ipc.setConfig('apiBase', this.apiBase.trim());
|
||||||
|
this.show('API URL сохранён');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onGatewayChange() {
|
||||||
|
try {
|
||||||
|
await this.ipc.setConfig('gateway', this.gateway || '');
|
||||||
|
this.show('Gateway сохранён');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onTargetsChange() {
|
||||||
|
try {
|
||||||
|
await this.ipc.setConfig('inlineTargets', this.targets || '');
|
||||||
|
this.show('inlineTargets сохранёны');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDelayChange() {
|
||||||
|
try {
|
||||||
|
await this.ipc.setConfig('delay', this.delay || '');
|
||||||
|
this.show('Задержка сохранёна');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
toYamlFromInline() {
|
||||||
|
this.configYAML = this.knock.convertInlineToYaml(
|
||||||
|
this.targets,
|
||||||
|
this.delay,
|
||||||
|
this.waitConnection
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fromYamlToInline() {
|
||||||
|
const r = this.knock.convertYamlToInline(this.configYAML);
|
||||||
|
this.targets = r.targets;
|
||||||
|
this.delay = r.delay;
|
||||||
|
this.waitConnection = r.waitConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
onServerFilePathInput(newPath: string) {
|
||||||
|
this.configYAML = this.knock.patchYamlPath(this.configYAML, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpenFile() {
|
||||||
|
if (this.configYAML.trim() !== '') {
|
||||||
|
const confirmNew = await this.modal.showConfirm(
|
||||||
|
'Open saved configuration',
|
||||||
|
'This will replace the current YAML configuration with saved configuration. Continue?'
|
||||||
|
);
|
||||||
|
if (!confirmNew) return;
|
||||||
|
}
|
||||||
|
const res = await this.ipc.openFile();
|
||||||
|
if (res?.canceled || res.content === undefined) return;
|
||||||
|
this.suppressYamlDirty = true;
|
||||||
|
this.configYAML = res.content;
|
||||||
|
this.suppressYamlDirty = false;
|
||||||
|
this.yamlDirty = false;
|
||||||
|
|
||||||
|
// Update selected file and refresh file list
|
||||||
|
if (res.filePath) {
|
||||||
|
const fileName =
|
||||||
|
res.filePath.split('/').pop() || res.filePath.split('\\').pop() || '';
|
||||||
|
console.log('Open file - setting selectedConfigFile to:', fileName);
|
||||||
|
await this.loadConfigFiles(fileName, res.filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onNewConfiguration() {
|
||||||
|
if (this.configYAML.trim() !== '') {
|
||||||
|
const confirmNew = await this.modal.showConfirm(
|
||||||
|
'Create New Default Configuration',
|
||||||
|
'This will replace the current YAML configuration with a new default configuration. Continue?'
|
||||||
|
);
|
||||||
|
if (!confirmNew) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default configuration with 3 targets
|
||||||
|
this.suppressYamlDirty = true;
|
||||||
|
this.configYAML = `description: "Default configuration"
|
||||||
|
targets:
|
||||||
|
- protocol: tcp
|
||||||
|
host: 192.168.1.100
|
||||||
|
ports: [22]
|
||||||
|
wait_connection: true
|
||||||
|
gateway: ""
|
||||||
|
|
||||||
|
- protocol: udp
|
||||||
|
host: 192.168.1.101
|
||||||
|
ports: [53, 123]
|
||||||
|
wait_connection: false
|
||||||
|
gateway: ""
|
||||||
|
|
||||||
|
- protocol: tcp
|
||||||
|
host: 192.168.1.102
|
||||||
|
ports: [80, 443]
|
||||||
|
wait_connection: true
|
||||||
|
gateway: ""
|
||||||
|
|
||||||
|
delay: 2s
|
||||||
|
`;
|
||||||
|
this.suppressYamlDirty = false;
|
||||||
|
this.yamlDirty = false;
|
||||||
|
this.show('Success: New configuration loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSaveFile(): Promise<boolean> {
|
||||||
|
// Validate YAML content
|
||||||
|
const validation = this.validateYaml(this.configYAML);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
this.show(`Error: ${validation.error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSave = await this.modal.showConfirm(
|
||||||
|
'Confirm action to save Configuration',
|
||||||
|
'Are you sure you want to save this configuration?'
|
||||||
|
);
|
||||||
|
if (!confirmSave) return false;
|
||||||
|
|
||||||
|
// const confirmSave = await this.ipc.showNativeModal({
|
||||||
|
// title: 'Confirm Configuration Save',
|
||||||
|
// message: 'Are you sure you want to save this configuration?',
|
||||||
|
// buttons: [
|
||||||
|
// { id: 'cancel', label: 'Cancel', style: 'secondary' },
|
||||||
|
// { id: 'save', label: 'Save', style: 'primary' }
|
||||||
|
// ],
|
||||||
|
// colors: {
|
||||||
|
// background: '#aa1c3a',
|
||||||
|
// text: '#ffffff',
|
||||||
|
// buttonBg: '#ffffff',
|
||||||
|
// buttonText: '#aa1c3a',
|
||||||
|
// secondaryBg: 'rgba(255,255,255,0.1)',
|
||||||
|
// secondaryText: '#ffffff'
|
||||||
|
// },
|
||||||
|
// buttonStyles: {
|
||||||
|
// save: { bg: '#e53935', text: '#fff' },
|
||||||
|
// cancel: { bg: 'rgba(255,255,255,0.1)', text: '#ffffff' }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (confirmSave.buttonId !== 'save') return;
|
||||||
|
|
||||||
|
// Use current file name if available, otherwise suggest new name
|
||||||
|
let suggested = '';
|
||||||
|
if (this.selectedConfigFile === 'Select config file...') {
|
||||||
|
suggested = this.knock.isEncryptedYaml(this.configYAML)
|
||||||
|
? 'new_config.encrypted'
|
||||||
|
: 'new_config.yaml';
|
||||||
|
} else {
|
||||||
|
suggested =
|
||||||
|
this.selectedConfigFile ||
|
||||||
|
(this.knock.isEncryptedYaml(this.configYAML)
|
||||||
|
? 'new_config.encrypted'
|
||||||
|
: 'new_config.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Save file - selectedConfigFile:', this.selectedConfigFile);
|
||||||
|
console.log('Save file - suggested name:', suggested);
|
||||||
|
|
||||||
|
const res = await this.ipc.saveAs({
|
||||||
|
suggestedName: suggested,
|
||||||
|
content: this.configYAML,
|
||||||
|
});
|
||||||
|
if (!res?.canceled && res.filePath) {
|
||||||
|
this.show(`Success: Configuration saved\n${JSON.stringify(res)}`);
|
||||||
|
// Update selected file and refresh file list
|
||||||
|
const filePath = res.filePath;
|
||||||
|
const fileName =
|
||||||
|
filePath.split('/').pop() || filePath.split('\\').pop() || '';
|
||||||
|
const folderPath = filePath.substring(0, filePath.lastIndexOf(fileName));
|
||||||
|
console.log('Save file - folderPath:', folderPath);
|
||||||
|
console.log('Save file - fileName:', fileName);
|
||||||
|
|
||||||
|
await this.loadConfigFiles(fileName, folderPath);
|
||||||
|
this.yamlDirty = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.show('Error: Failed to save configuration');
|
||||||
|
|
||||||
|
// if (!res?.canceled && res.filePath) {
|
||||||
|
// await this.ipc.revealInFolder(res.filePath);
|
||||||
|
// }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSaveCurrent(): Promise<boolean> {
|
||||||
|
const currentName =
|
||||||
|
this.previousSelectedConfigFile || this.selectedConfigFile || '';
|
||||||
|
|
||||||
|
// Validate YAML content
|
||||||
|
const validation = this.validateYaml(this.configYAML);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
this.show(`Error: ${validation.error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.yamlDirty) {
|
||||||
|
const proceed = await this.modal.showConfirm(
|
||||||
|
'No changes detected',
|
||||||
|
'YAML has no unsaved changes. Save anyway?'
|
||||||
|
);
|
||||||
|
if (!proceed) return false;
|
||||||
|
}
|
||||||
|
if (!currentName) {
|
||||||
|
// fallback to Save As
|
||||||
|
const ok = await this.onSaveFile();
|
||||||
|
if (ok) this.show('Success: Configuration saved');
|
||||||
|
else this.show('Error: Failed to save configuration');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
const r = await this.ipc.saveSilent({
|
||||||
|
fileName: currentName,
|
||||||
|
content: this.configYAML,
|
||||||
|
});
|
||||||
|
if (r && r.canceled === false && r.filePath) {
|
||||||
|
const filePath = r.filePath;
|
||||||
|
const fileName =
|
||||||
|
filePath.split('/').pop() || filePath.split('\\').pop() || '';
|
||||||
|
const folderPath = filePath.substring(0, filePath.lastIndexOf(fileName));
|
||||||
|
this.yamlDirty = false;
|
||||||
|
await this.loadConfigFiles(fileName, folderPath);
|
||||||
|
this.show('Success: Configuration saved');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.show('Error: Failed to save configuration');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onEncrypt() {
|
||||||
|
const r = await this.knock.encryptYaml(
|
||||||
|
this.apiBase,
|
||||||
|
this.password,
|
||||||
|
this.configYAML
|
||||||
|
);
|
||||||
|
const encrypted = r?.encrypted || '';
|
||||||
|
this.configYAML = encrypted;
|
||||||
|
await this.setYamlDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDecrypt() {
|
||||||
|
if (!this.knock.isEncryptedYaml(this.configYAML)) return;
|
||||||
|
const r = await this.knock.decryptYaml(
|
||||||
|
this.apiBase,
|
||||||
|
this.password,
|
||||||
|
this.configYAML
|
||||||
|
);
|
||||||
|
const plain = r?.yaml || '';
|
||||||
|
this.configYAML = plain;
|
||||||
|
await this.setYamlDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onExecute() {
|
||||||
|
this.show('Выполнение…');
|
||||||
|
const useLocalKnock =
|
||||||
|
!this.apiBase ||
|
||||||
|
this.apiBase.trim() === '' ||
|
||||||
|
this.apiBase === 'internal' ||
|
||||||
|
this.apiBase === '-' ||
|
||||||
|
this.apiBase === 'embedded' ||
|
||||||
|
this.apiBase === 'local';
|
||||||
|
try {
|
||||||
|
if (useLocalKnock) {
|
||||||
|
let targetsList: string[] = [];
|
||||||
|
if (this.mode === 'inline')
|
||||||
|
targetsList = this.targets.split(';').filter((t) => t.trim());
|
||||||
|
else if (this.mode === 'form')
|
||||||
|
targetsList = this.buildInlineFromForm()
|
||||||
|
.split(';')
|
||||||
|
.filter((t) => t.trim());
|
||||||
|
else if (this.mode === 'yaml') {
|
||||||
|
const parsed = this.knock.convertYamlToInline(this.configYAML);
|
||||||
|
targetsList = parsed.targets.split(';').filter(Boolean);
|
||||||
|
this.delay = parsed.delay;
|
||||||
|
}
|
||||||
|
if (targetsList.length === 0) {
|
||||||
|
this.show('Нет целей для простукивания');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let result;
|
||||||
|
if (this.mode === 'form') {
|
||||||
|
// perform per-target gateway if provided
|
||||||
|
const results: any[] = [];
|
||||||
|
for (let i = 0; i < this.formTargets.length; i++) {
|
||||||
|
const t = this.formTargets[i];
|
||||||
|
const targetStr = `${t.proto}:${(t.host || '').trim()}:${
|
||||||
|
Number(t.port) || 0
|
||||||
|
}`;
|
||||||
|
if (!/^(tcp|udp):[^:]+:\d+$/.test(targetStr)) continue;
|
||||||
|
// call one-by-one to allow different gateway per target
|
||||||
|
// delay between calls will be respected by the main process per target list,
|
||||||
|
// so we call with single-element targets to mimic sequence
|
||||||
|
const singleResult = await this.ipc.localKnock({
|
||||||
|
targets: [targetStr],
|
||||||
|
delay: this.delay,
|
||||||
|
verbose: this.verbose,
|
||||||
|
gateway: (t.gateway || '').trim() || this.gateway?.trim() || '',
|
||||||
|
});
|
||||||
|
results.push(singleResult);
|
||||||
|
}
|
||||||
|
// emulate summary
|
||||||
|
const okCount = results.filter((r) => r?.success).length;
|
||||||
|
result = {
|
||||||
|
success: okCount === results.length,
|
||||||
|
summary: { total: results.length, successful: okCount },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
result = await this.ipc.localKnock({
|
||||||
|
targets: targetsList,
|
||||||
|
delay: this.delay,
|
||||||
|
verbose: this.verbose,
|
||||||
|
gateway: this.gateway?.trim() || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result?.success) {
|
||||||
|
const s = result.summary;
|
||||||
|
this.show(
|
||||||
|
`Успех: Локальное простукивание завершено: ${s.successful}/${s.total} успешно`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.show(
|
||||||
|
`Ошибка локального простукивания: ${result?.error || 'unknown'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: any = {};
|
||||||
|
if (this.mode === 'yaml') {
|
||||||
|
// Prepare YAML: decrypt on-the-fly if needed, without mutating editor content
|
||||||
|
let yamlToSend = this.configYAML;
|
||||||
|
if (this.knock.isEncryptedYaml(yamlToSend)) {
|
||||||
|
try {
|
||||||
|
const dec = await this.knock.decryptYaml(
|
||||||
|
this.apiBase,
|
||||||
|
this.password,
|
||||||
|
yamlToSend
|
||||||
|
);
|
||||||
|
yamlToSend = dec?.yaml || '';
|
||||||
|
if (!yamlToSend) {
|
||||||
|
this.show('Error: failed to decrypt configuration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.show(`Error: decryption failed - ${e?.message || e}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.config_yaml = yamlToSend;
|
||||||
|
console.log('Execute - config_yaml:', yamlToSend);
|
||||||
|
} else if (this.mode === 'inline') {
|
||||||
|
body.targets = this.targets;
|
||||||
|
body.delay = this.delay;
|
||||||
|
body.verbose = this.verbose;
|
||||||
|
body.waitConnection = this.waitConnection;
|
||||||
|
body.gateway = this.gateway;
|
||||||
|
console.log('Execute - targets:', this.targets);
|
||||||
|
console.log('Execute - delay:', this.delay);
|
||||||
|
console.log('Execute - verbose:', this.verbose);
|
||||||
|
console.log('Execute - waitConnection:', this.waitConnection);
|
||||||
|
console.log('Execute - gateway:', this.gateway);
|
||||||
|
} else {
|
||||||
|
body.targets = this.targets;
|
||||||
|
body.delay = this.delay;
|
||||||
|
body.verbose = this.verbose;
|
||||||
|
body.waitConnection = this.waitConnection;
|
||||||
|
}
|
||||||
|
const res = await this.knock.knockViaHttp(
|
||||||
|
this.apiBase,
|
||||||
|
this.password,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
if ((res as any)?.ok) this.show('Успех: успешно простучали через API...');
|
||||||
|
else this.show(`Ошибка API: ${(res as any).statusText}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.show(`Ошибка: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
show(msg: string) {
|
||||||
|
this.status = msg;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.status = '';
|
||||||
|
}, 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example modal usage methods
|
||||||
|
async showCustomModal() {
|
||||||
|
const result = await this.modal.show({
|
||||||
|
title: 'Custom Dialog',
|
||||||
|
message:
|
||||||
|
'This is a custom modal with 3 buttons. Which one will you choose?',
|
||||||
|
buttons: [
|
||||||
|
{ id: 'option1', label: 'Option 1', style: 'primary' },
|
||||||
|
{ id: 'option2', label: 'Option 2', style: 'secondary' },
|
||||||
|
{ id: 'cancel', label: 'Cancel', style: 'danger' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.show(`You clicked: ${result.buttonLabel} (ID: ${result.buttonId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showConfirmDialog() {
|
||||||
|
const confirmed = await this.modal.showConfirm(
|
||||||
|
'Confirm Action',
|
||||||
|
'Are you sure you want to proceed with this action?'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.show(confirmed ? 'Action confirmed!' : 'Action cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
async showYesNoCancelDialog() {
|
||||||
|
const result = await this.modal.showYesNoCancel(
|
||||||
|
'Save Changes',
|
||||||
|
'Do you want to save your changes before closing?'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.show(`You chose: ${result}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showInfoDialog() {
|
||||||
|
await this.modal.showInfo(
|
||||||
|
'Information',
|
||||||
|
'This is an informational dialog with just an OK button.'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.show('Info dialog closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { IpcService } from '../ipc.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-settings',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">⚙️ Настройки приложения</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="configJson">Конфигурация (JSON формат):</label>
|
||||||
|
<textarea id="configJson" [(ngModel)]="jsonText" placeholder="Загрузка конфигурации..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn-secondary" (click)="onCancel()">Вернуться</button>
|
||||||
|
<button class="btn-primary" (click)="onSave()">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status" [class.success]="statusType==='success'" [class.error]="statusType==='error'">{{status}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container { max-width: 100%; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: hidden; }
|
||||||
|
.header { background: #2c3e50; color: white; padding: 15px 20px; font-size: 18px; font-weight: 600; }
|
||||||
|
.content { padding: 20px; }
|
||||||
|
.field-group { margin-bottom: 20px; }
|
||||||
|
label { display: block; margin-bottom: 8px; font-weight: 500; color: #333; }
|
||||||
|
textarea { width: 100%; height: 300px; padding: 12px; border: 2px solid #ddd; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.4; resize: vertical; box-sizing: border-box; }
|
||||||
|
.buttons { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; }
|
||||||
|
.btn-primary { background: #3498db; color: white; padding: 10px 20px; border: none; border-radius: 6px; }
|
||||||
|
.btn-secondary { background: #95a5a6; color: white; padding: 10px 20px; border: none; border-radius: 6px; }
|
||||||
|
.status { margin-top: 10px; padding: 8px 12px; border-radius: 4px; font-size: 13px; display: none; }
|
||||||
|
.status.success { display: block; background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||||
|
.status.error { display: block; background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class SettingsComponent implements OnInit {
|
||||||
|
jsonText = '';
|
||||||
|
status = '';
|
||||||
|
statusType: 'success' | 'error' | '' = '';
|
||||||
|
|
||||||
|
constructor(private ipc: IpcService) {}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
try {
|
||||||
|
const cfg = await this.ipc.getAllConfig();
|
||||||
|
this.jsonText = JSON.stringify(cfg || {}, null, 2);
|
||||||
|
} catch {
|
||||||
|
this.jsonText = '{}';
|
||||||
|
this.show('Ошибка загрузки конфигурации', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSave() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.jsonText);
|
||||||
|
const res = await this.ipc.setAllConfig(parsed);
|
||||||
|
if (res?.ok) this.show('Конфигурация успешно сохранена', 'success');
|
||||||
|
else this.show(`Ошибка сохранения: ${res?.error || 'unknown'}`, 'error');
|
||||||
|
} catch (e: any) {
|
||||||
|
this.show(`Неверный JSON: ${e?.message || e}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onCancel() {
|
||||||
|
await this.ipc.closeSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private show(msg: string, type: 'success'|'error') {
|
||||||
|
this.status = msg;
|
||||||
|
this.statusType = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
0
desktop-angular/src/frontend/src/assets/.gitkeep
Normal file
BIN
desktop-angular/src/frontend/src/assets/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
desktop-angular/src/frontend/src/assets/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
1
desktop-angular/src/frontend/src/assets/logo.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Port kicker
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const logFunction = (...messages: any[]) => {};
|
||||||
|
const errorLogFunction = (...messages: any[]) => {};
|
||||||
|
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiBaseUrl: '/api/v1',
|
||||||
|
adminApiUrl: '/api/v1/project',
|
||||||
|
log: logFunction,
|
||||||
|
errLog: errorLogFunction,
|
||||||
|
debugAny: (
|
||||||
|
something: any,
|
||||||
|
transformer: (...args: any[]) => any = (...args: any[]): any => {
|
||||||
|
return args[0];
|
||||||
|
}
|
||||||
|
) => transformer(something),
|
||||||
|
};
|
||||||
26
desktop-angular/src/frontend/src/environments/environment.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
|
||||||
|
const logFunction = (...messages: any[]) => {
|
||||||
|
messages.forEach((msg) => console.log(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorLogFunction = (...messages: any[]) => {
|
||||||
|
messages.forEach((msg) => console.error(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const debugAny = (
|
||||||
|
something: any,
|
||||||
|
transformer: (...args: any[]) => any = (...args: any[]): any => {
|
||||||
|
return args[0];
|
||||||
|
}
|
||||||
|
) => transformer(something);
|
||||||
|
|
||||||
|
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiBaseUrl: 'http://localhost:8080/api/v1',
|
||||||
|
adminApiUrl: 'http://localhost:8080/api/v1/project',
|
||||||
|
log: logFunction,
|
||||||
|
errLog: errorLogFunction,
|
||||||
|
debugAny
|
||||||
|
};
|
||||||
BIN
desktop-angular/src/frontend/src/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
16
desktop-angular/src/frontend/src/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Port-Knocker UI</title>
|
||||||
|
<base href="./">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#1976d2">
|
||||||
|
</head>
|
||||||
|
<body class="mat-typography">
|
||||||
|
<app-root></app-root>
|
||||||
|
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
desktop-angular/src/frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { RootComponent } from './app/root.component';
|
||||||
|
|
||||||
|
bootstrapApplication(RootComponent, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
59
desktop-angular/src/frontend/src/manifest.webmanifest
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "weather-app",
|
||||||
|
"short_name": "weather-app",
|
||||||
|
"theme_color": "#1976d2",
|
||||||
|
"background_color": "#fafafa",
|
||||||
|
"display": "standalone",
|
||||||
|
"scope": "./",
|
||||||
|
"start_url": "./",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
96
desktop-angular/src/frontend/src/styles.scss
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
header,
|
||||||
|
footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modes label {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constant-mode-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: 280px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
background: #1f2937;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
margin-left: 12px;
|
||||||
|
color: #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#targetsList .target-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr 120px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#targetsList .remove {
|
||||||
|
background: #7f1d1d;
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
}
|
||||||
14
desktop-angular/src/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/main.ts"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
32
desktop-angular/src/frontend/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"dom"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
14
desktop-angular/src/frontend/tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"jasmine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
933
desktop-angular/src/main/main.js
Normal file
@@ -0,0 +1,933 @@
|
|||||||
|
// desktop-angular/src/main/main.js
|
||||||
|
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const net = require("net");
|
||||||
|
const dgram = require("dgram");
|
||||||
|
const os = require("os");
|
||||||
|
// const { log } = require("console");
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV !== "production" && !app.isPackaged;
|
||||||
|
|
||||||
|
const log = (...messages) => {
|
||||||
|
if (isDev) {
|
||||||
|
console.log(...messages);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global variable to store current file name
|
||||||
|
let currentFileName = null;
|
||||||
|
|
||||||
|
let configsYamlIsDirty = false;
|
||||||
|
|
||||||
|
// Global variable to store main window reference
|
||||||
|
let mainWindow = null;
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 900,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, "../preload/preload.js"),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show DevTools in development mode
|
||||||
|
if (isDev) {
|
||||||
|
win.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the beforeunload event in renderer handle unsaved changes
|
||||||
|
// This is simpler and more reliable
|
||||||
|
|
||||||
|
win.on("ready-to-show", () => win.show());
|
||||||
|
|
||||||
|
win.on("close", (e) => {
|
||||||
|
if (!configsYamlIsDirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageBoxOptions = {
|
||||||
|
type: "question",
|
||||||
|
buttons: ["Cancel", "Quit without saving"],
|
||||||
|
title: "Несохранённые изменения",
|
||||||
|
message: "У вас есть несохранённые изменения в конфигурации. Вы уверены, что хотите выйти без сохранения?",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = dialog.showMessageBoxSync(null, messageBoxOptions);
|
||||||
|
if (response === 0) { // Cancel
|
||||||
|
e.preventDefault();
|
||||||
|
log("Close cancelled by user");
|
||||||
|
} else { // Quit without saving
|
||||||
|
configsYamlIsDirty = false;
|
||||||
|
log("User chose to quit without saving");
|
||||||
|
// Allow the window to close
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
log("Development mode: loading from localhost:4200");
|
||||||
|
win.loadURL("http://localhost:4200");
|
||||||
|
win.webContents.openDevTools(); // Открываем DevTools в режиме разработки
|
||||||
|
// Store main window reference
|
||||||
|
mainWindow = win;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// В PROD грузим из собранного Angular
|
||||||
|
const indexPath = app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, "ui-dist", "index.html")
|
||||||
|
: path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../frontend/dist/project-front/browser/index.html"
|
||||||
|
);
|
||||||
|
|
||||||
|
log("Production mode: loading from", indexPath);
|
||||||
|
log("app.isPackaged:", app.isPackaged);
|
||||||
|
log("process.env.NODE_ENV:", process.env.NODE_ENV);
|
||||||
|
|
||||||
|
win.loadFile(indexPath);
|
||||||
|
|
||||||
|
// Store main window reference
|
||||||
|
mainWindow = win;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
const devGoBin = path.resolve(__dirname, "../../bin/full-go-knocker");
|
||||||
|
const prodGoBin = path.resolve(
|
||||||
|
process.resourcesPath || path.resolve(__dirname, "../../"),
|
||||||
|
"bin/full-go-knocker"
|
||||||
|
);
|
||||||
|
let serverExec;
|
||||||
|
if (fs.existsSync(devGoBin)) {
|
||||||
|
serverExec = devGoBin;
|
||||||
|
log("Using Go-knocker server (dev)");
|
||||||
|
} else if (fs.existsSync(prodGoBin)) {
|
||||||
|
serverExec = prodGoBin;
|
||||||
|
log("Using Go-knocker server (prod)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
|
||||||
|
if (serverExec) {
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
GO_KNOCKER_SERVE_PASS: process.env.KNOCKER_DESKTOP_PASS || "superpass",
|
||||||
|
GO_KNOCKER_SERVE_PORT: process.env.KNOCKER_DESKTOP_PORT || "8888",
|
||||||
|
};
|
||||||
|
|
||||||
|
const serveKnockerEnvValue = process.env.KNOCKER_DESKTOP_SERVE || "true";
|
||||||
|
const serveKnocker =
|
||||||
|
serveKnockerEnvValue.toLowerCase() === "true" ||
|
||||||
|
serveKnockerEnvValue.toLowerCase() === "1";
|
||||||
|
|
||||||
|
// если serveKnocker, то запускаем сервер
|
||||||
|
if (serveKnocker) {
|
||||||
|
const serverProcess = spawn(serverExec, ["serve"], { env });
|
||||||
|
|
||||||
|
app.on("before-quit", (event) => {
|
||||||
|
log("Before quit event triggered, configsYamlIsDirty:", configsYamlIsDirty);
|
||||||
|
|
||||||
|
// Check for unsaved changes
|
||||||
|
if (configsYamlIsDirty) {
|
||||||
|
event.preventDefault(); // Prevent quit
|
||||||
|
|
||||||
|
const messageBoxOptions = {
|
||||||
|
type: "question",
|
||||||
|
buttons: ["Cancel", "Quit without saving"],
|
||||||
|
title: "Несохранённые изменения",
|
||||||
|
message: "У вас есть несохранённые изменения в конфигурации. Вы уверены, что хотите выйти без сохранения?",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = dialog.showMessageBoxSync(null, messageBoxOptions);
|
||||||
|
if (response === 0) { // Cancel
|
||||||
|
log("App quit cancelled by user");
|
||||||
|
return; // Stay in app
|
||||||
|
} else { // Quit without saving
|
||||||
|
log("User chose to quit without saving");
|
||||||
|
// Allow quit to proceed
|
||||||
|
if (serverProcess && serveKnocker) {
|
||||||
|
serverProcess.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No unsaved changes, proceed with normal quit
|
||||||
|
if (serverProcess && serveKnocker) {
|
||||||
|
serverProcess.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.stdout.on("data", (data) => {
|
||||||
|
log(`Server stdout: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.stderr.on("data", (data) => {
|
||||||
|
log(`Server stderr: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.on("close", (code) => {
|
||||||
|
log(`Server process exited with code ${code}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log("No server executable found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", (e) => {
|
||||||
|
if (process.platform !== "darwin") app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------- Persistent config helpers --------------------
|
||||||
|
let configCache = null;
|
||||||
|
function getConfigPath() {
|
||||||
|
return path.join(app.getPath("userData"), "config.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
if (configCache) return configCache;
|
||||||
|
const cfgPath = getConfigPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(cfgPath)) {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
||||||
|
configCache = parsed || {};
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to read config file:", e);
|
||||||
|
}
|
||||||
|
configCache = {};
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig(partial) {
|
||||||
|
const current = loadConfig();
|
||||||
|
const next = { ...current, ...partial };
|
||||||
|
configCache = next;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true });
|
||||||
|
fs.writeFileSync(getConfigPath(), JSON.stringify(next, null, 2), "utf-8");
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save config file:", e);
|
||||||
|
return { ok: false, error: e?.message || String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- IPC handlers --------------------
|
||||||
|
ipcMain.handle("config:get", async (_e, key) => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
if (key) return cfg[key];
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("config:set", async (_e, key, value) => {
|
||||||
|
return saveConfig({ [key]: value });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("config:getAll", async () => {
|
||||||
|
return loadConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Config files management
|
||||||
|
ipcMain.handle("config:listFiles", async () => {
|
||||||
|
try {
|
||||||
|
const configsDir = path.join(app.getPath("userData"), "configs");
|
||||||
|
if (!fs.existsSync(configsDir)) {
|
||||||
|
return { files: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(configsDir);
|
||||||
|
const configFiles = files.filter((file) => {
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
return [".yaml", ".yml", ".encrypted", ".txt"].includes(ext);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { files: configFiles };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error listing config files:", error);
|
||||||
|
return { files: [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("config:loadFile", async (_e, fileName) => {
|
||||||
|
try {
|
||||||
|
const configsDir = path.join(app.getPath("userData"), "configs");
|
||||||
|
const filePath = path.join(configsDir, fileName);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return { success: false, error: "File not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
return { success: true, content };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading config file:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("config:setAll", async (_e, newConfig) => {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
getConfigPath(),
|
||||||
|
JSON.stringify(newConfig || {}, null, 2),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
configCache = newConfig || {};
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e?.message || String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("settings:close", () => {
|
||||||
|
const focused = BrowserWindow.getFocusedWindow();
|
||||||
|
if (focused) {
|
||||||
|
focused.close();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: false, error: "No focused window" };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("file:open", async () => {
|
||||||
|
// Create configs directory if it doesn't exist
|
||||||
|
const userDataDir = app.getPath("userData");
|
||||||
|
const configsDir = path.join(userDataDir, "configs");
|
||||||
|
|
||||||
|
if (!fs.existsSync(configsDir)) {
|
||||||
|
fs.mkdirSync(configsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use custom file dialog
|
||||||
|
const result = await openCustomFileDialog({
|
||||||
|
title: "Open Configuration File",
|
||||||
|
defaultPath: configsDir,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "YAML/Encrypted Files",
|
||||||
|
extensions: ["yaml", "yml", "encrypted", "txt"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "All Files",
|
||||||
|
extensions: ["*"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
background: "#2d3748",
|
||||||
|
text: "#ffffff",
|
||||||
|
buttonBg: "#aa1c3a",
|
||||||
|
buttonText: "#ffffff",
|
||||||
|
border: "rgba(255,255,255,0.2)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save the filename if file was opened successfully
|
||||||
|
if (!result.canceled && result.filePath) {
|
||||||
|
currentFileName = path.basename(result.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("file:saveAs", async (_e, payload) => {
|
||||||
|
// Create configs directory if it doesn't exist
|
||||||
|
const configsDir = path.join(app.getPath("userData"), "configs");
|
||||||
|
if (!fs.existsSync(configsDir)) {
|
||||||
|
fs.mkdirSync(configsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use custom save dialog
|
||||||
|
const result = await openCustomSaveDialog({
|
||||||
|
title: "Save Configuration File",
|
||||||
|
defaultPath: configsDir,
|
||||||
|
suggestedName:
|
||||||
|
payload?.suggestedName || currentFileName || "new_config.yaml",
|
||||||
|
content: payload.content || "",
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "YAML/Encrypted Files",
|
||||||
|
extensions: ["yaml", "yml", "encrypted", "txt"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "All Files",
|
||||||
|
extensions: ["*"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
background: "#2d3748",
|
||||||
|
text: "#ffffff",
|
||||||
|
buttonBg: "#aa1c3a",
|
||||||
|
buttonText: "#ffffff",
|
||||||
|
border: "rgba(255,255,255,0.2)",
|
||||||
|
inputBg: "rgba(255,255,255,0.1)",
|
||||||
|
inputBorder: "rgba(255,255,255,0.3)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update current file name if file was saved successfully
|
||||||
|
if (!result.canceled && result.filePath) {
|
||||||
|
currentFileName = path.basename(result.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Silent save to current file in configs dir without dialogs
|
||||||
|
ipcMain.handle("file:saveSilent", async (_e, payload) => {
|
||||||
|
try {
|
||||||
|
const configsDir = path.join(app.getPath("userData"), "configs");
|
||||||
|
if (!fs.existsSync(configsDir)) {
|
||||||
|
fs.mkdirSync(configsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target file name
|
||||||
|
let fileName =
|
||||||
|
(payload && payload.fileName) || currentFileName || "new_config.yaml";
|
||||||
|
// Basic sanitization
|
||||||
|
fileName = path.basename(fileName);
|
||||||
|
const filePath = path.join(configsDir, fileName);
|
||||||
|
|
||||||
|
const content = (payload && payload.content) || "";
|
||||||
|
fs.writeFileSync(filePath, content, "utf-8");
|
||||||
|
|
||||||
|
currentFileName = path.basename(filePath);
|
||||||
|
return { canceled: false, filePath };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Silent save failed:", error);
|
||||||
|
return { canceled: true, error: error && error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("os:revealInFolder", async (_e, filePath) => {
|
||||||
|
try {
|
||||||
|
shell.showItemInFolder(filePath);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e && e.message) || String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------- Custom Electron Modal --------------------
|
||||||
|
function openCustomModalWindow(config) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const modal = new BrowserWindow({
|
||||||
|
width: 560,
|
||||||
|
height: 340,
|
||||||
|
resizable: false,
|
||||||
|
modal: true,
|
||||||
|
parent: BrowserWindow.getFocusedWindow() || undefined,
|
||||||
|
show: false,
|
||||||
|
frame: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: true,
|
||||||
|
contextIsolation: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.once("ready-to-show", () => {
|
||||||
|
modal.show();
|
||||||
|
// Show DevTools in development mode
|
||||||
|
if (isDev) {
|
||||||
|
modal.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
modal.on("closed", () => resolve({ buttonId: "closed", buttonIndex: -1 }));
|
||||||
|
|
||||||
|
modal.loadFile(path.join(__dirname, "modal.html"));
|
||||||
|
|
||||||
|
// Send config after load
|
||||||
|
modal.webContents.on("did-finish-load", () => {
|
||||||
|
modal.webContents.send("custom-modal:config", config || {});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.once("custom-modal:result", (_evt, result) => {
|
||||||
|
try {
|
||||||
|
modal.close();
|
||||||
|
} catch {}
|
||||||
|
resolve(result || { buttonId: "unknown", buttonIndex: -1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Custom File Dialog --------------------
|
||||||
|
function openCustomFileDialog(config) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const dialog = new BrowserWindow({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
minWidth: 600,
|
||||||
|
minHeight: 400,
|
||||||
|
resizable: true,
|
||||||
|
modal: true,
|
||||||
|
parent: BrowserWindow.getFocusedWindow() || undefined,
|
||||||
|
show: false,
|
||||||
|
frame: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: true,
|
||||||
|
contextIsolation: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.once("ready-to-show", () => {
|
||||||
|
dialog.show();
|
||||||
|
// Show DevTools in development mode
|
||||||
|
if (isDev) {
|
||||||
|
dialog.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.on("closed", () => resolve({ canceled: true }));
|
||||||
|
|
||||||
|
dialog.loadFile(path.join(__dirname, "open-dialog.html"));
|
||||||
|
|
||||||
|
// Send config after load
|
||||||
|
dialog.webContents.on("did-finish-load", () => {
|
||||||
|
dialog.webContents.send("file-dialog:config", config || {});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.once("file-dialog:result", (_evt, result) => {
|
||||||
|
try {
|
||||||
|
dialog.close();
|
||||||
|
} catch {}
|
||||||
|
resolve(result || { canceled: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Custom Save Dialog --------------------
|
||||||
|
function openCustomSaveDialog(config) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const dialog = new BrowserWindow({
|
||||||
|
width: 800,
|
||||||
|
height: 650,
|
||||||
|
minWidth: 500,
|
||||||
|
minHeight: 350,
|
||||||
|
resizable: true,
|
||||||
|
modal: true,
|
||||||
|
parent: BrowserWindow.getFocusedWindow() || undefined,
|
||||||
|
show: false,
|
||||||
|
frame: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: true,
|
||||||
|
contextIsolation: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.once("ready-to-show", () => {
|
||||||
|
dialog.show();
|
||||||
|
// Show DevTools in development mode
|
||||||
|
if (isDev) {
|
||||||
|
dialog.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.on("closed", () => resolve({ canceled: true }));
|
||||||
|
|
||||||
|
dialog.loadFile(path.join(__dirname, "save-dialog.html"));
|
||||||
|
|
||||||
|
// Send config after load
|
||||||
|
dialog.webContents.on("did-finish-load", () => {
|
||||||
|
dialog.webContents.send("save-dialog:config", config || {});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.once("save-dialog:result", (_evt, result) => {
|
||||||
|
try {
|
||||||
|
dialog.close();
|
||||||
|
} catch {}
|
||||||
|
resolve(result || { canceled: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle("dialog:custom", async (_e, payload) => {
|
||||||
|
const cfg = payload || {};
|
||||||
|
return await openCustomModalWindow(cfg);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("dialog:customFile", async (_e, payload) => {
|
||||||
|
const cfg = payload || {};
|
||||||
|
return await openCustomFileDialog(cfg);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("dialog:customSave", async (_e, payload) => {
|
||||||
|
const cfg = payload || {};
|
||||||
|
return await openCustomSaveDialog(cfg);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Directory picker handler
|
||||||
|
ipcMain.handle("dialog:showDirectoryPicker", async (_e, options) => {
|
||||||
|
try {
|
||||||
|
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||||
|
return await dialog.showOpenDialog(focusedWindow, {
|
||||||
|
title: options.title || "Select Directory",
|
||||||
|
defaultPath: options.defaultPath,
|
||||||
|
properties: ["openDirectory"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error opening directory picker:", error);
|
||||||
|
return { canceled: true, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("network:interfaces", async () => {
|
||||||
|
try {
|
||||||
|
const interfaces = os.networkInterfaces();
|
||||||
|
const result = {};
|
||||||
|
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||||
|
result[name] = addrs.map((addr) => ({
|
||||||
|
address: addr.address,
|
||||||
|
family: addr.family,
|
||||||
|
internal: addr.internal,
|
||||||
|
mac: addr.mac,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return { success: true, interfaces: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("network:test-connection", async (_e, payload) => {
|
||||||
|
try {
|
||||||
|
const { host, port, localAddress } = payload || {};
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let resolved = false;
|
||||||
|
function safeResolve(result) {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.setTimeout(5000);
|
||||||
|
socket.on("connect", () => {
|
||||||
|
const localAddr = socket.localAddress;
|
||||||
|
const localPort = socket.localPort;
|
||||||
|
safeResolve({
|
||||||
|
success: true,
|
||||||
|
message: `Connection successful from ${localAddr}:${localPort}`,
|
||||||
|
localAddress: localAddr,
|
||||||
|
localPort,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on("error", (err) =>
|
||||||
|
safeResolve({ success: false, error: err.message })
|
||||||
|
);
|
||||||
|
socket.on("timeout", () =>
|
||||||
|
safeResolve({ success: false, error: "Connection timeout" })
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (localAddress) {
|
||||||
|
socket.connect({ port, host, localAddress });
|
||||||
|
} else {
|
||||||
|
socket.connect(port, host);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
safeResolve({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function knockTcp(host, port, timeout = 5000, gateway = null) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let resolved = false;
|
||||||
|
function safeResolve(result) {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch {}
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
socket.setTimeout(timeout);
|
||||||
|
socket.on("connect", () => {
|
||||||
|
const localAddr = socket.localAddress;
|
||||||
|
const localPort = socket.localPort;
|
||||||
|
safeResolve({
|
||||||
|
success: true,
|
||||||
|
message: `TCP connection to ${host}:${port} successful (from ${localAddr}:${localPort})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on("timeout", () =>
|
||||||
|
safeResolve({
|
||||||
|
success: false,
|
||||||
|
message: `TCP connection to ${host}:${port} timeout`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
socket.on("error", (err) =>
|
||||||
|
safeResolve({
|
||||||
|
success: false,
|
||||||
|
message: `TCP connection to ${host}:${port} failed: ${err.message}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (gateway?.trim()) {
|
||||||
|
socket.connect({ port, host, localAddress: gateway.trim() });
|
||||||
|
} else {
|
||||||
|
socket.connect(port, host);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
safeResolve({
|
||||||
|
success: false,
|
||||||
|
message: `TCP connection error: ${error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function knockUdp(host, port, timeout = 5000, gateway = null) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = dgram.createSocket("udp4");
|
||||||
|
const message = Buffer.from("knock");
|
||||||
|
let resolved = false;
|
||||||
|
function safeResolve(result) {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
socket.close();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.on("error", (err) =>
|
||||||
|
safeResolve({
|
||||||
|
success: false,
|
||||||
|
message: `UDP packet to ${host}:${port} failed: ${err.message}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (gateway && gateway.trim()) {
|
||||||
|
try {
|
||||||
|
socket.bind(0, gateway.trim());
|
||||||
|
} catch (bindError) {
|
||||||
|
safeResolve({
|
||||||
|
success: false,
|
||||||
|
message: `UDP bind failed: ${bindError.message}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.send(message, 0, message.length, port, host, (err) => {
|
||||||
|
if (err) {
|
||||||
|
safeResolve({
|
||||||
|
success: false,
|
||||||
|
message: `UDP packet failed: ${err.message}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const localAddr = socket.address()?.address;
|
||||||
|
const localPort = socket.address()?.port;
|
||||||
|
safeResolve({
|
||||||
|
success: true,
|
||||||
|
message: `UDP packet sent to ${host}:${port} (from ${localAddr}:${localPort})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() =>
|
||||||
|
safeResolve({
|
||||||
|
success: true,
|
||||||
|
message: `UDP packet sent to ${host}:${port} (timeout reached)`,
|
||||||
|
}),
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
socket.on("close", () => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function knockExternal(target, timeout = 5000) {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
const devGoBin = path.resolve(__dirname, "../../bin/full-go-knocker");
|
||||||
|
const prodGoBin = path.resolve(
|
||||||
|
process.resourcesPath || path.resolve(__dirname, "../../"),
|
||||||
|
"bin/full-go-knocker"
|
||||||
|
);
|
||||||
|
let knockerExec;
|
||||||
|
if (fs.existsSync(devGoBin)) {
|
||||||
|
knockerExec = devGoBin;
|
||||||
|
log("Using Go-knocker (dev)");
|
||||||
|
} else if (fs.existsSync(prodGoBin)) {
|
||||||
|
knockerExec = prodGoBin;
|
||||||
|
log("Using Go-knocker (prod)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
|
||||||
|
if (knockerExec) {
|
||||||
|
const knockProcess = spawn(knockerExec, ["-t", target, "-v"]);
|
||||||
|
let stderr = "";
|
||||||
|
let stdout = "";
|
||||||
|
|
||||||
|
knockProcess.stdout.on("data", (data) => {
|
||||||
|
stdout += data;
|
||||||
|
log(`Knocker stdout: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
knockProcess.stderr.on("data", (data) => {
|
||||||
|
stderr += data;
|
||||||
|
log(`Knocker stderr: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Таймаут на 15 секунд - вдруг что-то пойдёт не так
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
knockProcess.kill("SIGTERM");
|
||||||
|
}, timeout);
|
||||||
|
const code = await new Promise((resolve) =>
|
||||||
|
knockProcess.on("close", resolve)
|
||||||
|
);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
message: `go knocker exited with code ${code}: ${stderr || stdout}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
message: `External knock to ${target} successful`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log("No knocker executable found.");
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
message: `External knock to ${target} unsuccessful`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle("app:checkUnsavedChanges", async () => {
|
||||||
|
// This will be called by the renderer to check if there are unsaved changes
|
||||||
|
// We need to get the value from the renderer process
|
||||||
|
try {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
const result = await mainWindow.webContents.executeJavaScript(`
|
||||||
|
if (window.rootComponent && typeof window.rootComponent.checkUnsavedChanges === 'function') {
|
||||||
|
return window.rootComponent.checkUnsavedChanges();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
log("Error in app:checkUnsavedChanges:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("app:setYamlDirty", async (_e, isDirty) => {
|
||||||
|
// Sync yamlDirty state from renderer to main process
|
||||||
|
try {
|
||||||
|
configsYamlIsDirty = isDirty;
|
||||||
|
console.log("YAML dirty state synced:", configsYamlIsDirty);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log("Error in app:setYamlDirty:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("knock:local", async (_e, payload) => {
|
||||||
|
try {
|
||||||
|
if (!payload || typeof payload !== "object")
|
||||||
|
return { success: false, error: "Invalid payload" };
|
||||||
|
|
||||||
|
const { targets, delay, verbose, gateway } = payload;
|
||||||
|
|
||||||
|
if (!targets || !Array.isArray(targets) || targets.length === 0)
|
||||||
|
return { success: false, error: "No targets provided" };
|
||||||
|
|
||||||
|
const validTargets = targets.filter(
|
||||||
|
(t) => typeof t === "string" && t.trim().length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validTargets.length === 0)
|
||||||
|
return { success: false, error: "No valid targets provided" };
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
const delayMs = (function parseDelay() {
|
||||||
|
const delayStr = delay || "1s";
|
||||||
|
const match = delayStr?.match(/^(\d+)([smh]?)$/);
|
||||||
|
if (!match) return 1000;
|
||||||
|
const value = parseInt(match[1]);
|
||||||
|
const unit = match[2] || "s";
|
||||||
|
switch (unit) {
|
||||||
|
case "s":
|
||||||
|
return value * 1000;
|
||||||
|
case "m":
|
||||||
|
return value * 60 * 1000;
|
||||||
|
case "h":
|
||||||
|
return value * 60 * 60 * 1000;
|
||||||
|
default:
|
||||||
|
return value * 1000;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
for (let i = 0; i < validTargets.length; i++) {
|
||||||
|
const targetStr = validTargets[i];
|
||||||
|
const parts = targetStr.split(":");
|
||||||
|
|
||||||
|
const proto = (parts[0] || "udp").toLowerCase();
|
||||||
|
const host = parts[1] || "127.0.0.1";
|
||||||
|
const port = parseInt(parts[2] || "22", 10);
|
||||||
|
const targetGateway = parts[3] || gateway;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (targetGateway) {
|
||||||
|
result = await knockExternal(
|
||||||
|
`${proto}:${host}:${port}:${targetGateway}`,
|
||||||
|
5000
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (proto === "tcp")
|
||||||
|
result = await knockTcp(host, port, 5000, targetGateway);
|
||||||
|
else if (proto === "udp")
|
||||||
|
result = await knockUdp(host, port, 5000, targetGateway);
|
||||||
|
else
|
||||||
|
result = {
|
||||||
|
success: false,
|
||||||
|
message: `Unsupported protocol: ${proto}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ target: targetStr, ...result });
|
||||||
|
if (i < validTargets.length - 1 && delayMs > 0)
|
||||||
|
await new Promise((r) => setTimeout(r, delayMs));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
summary: {
|
||||||
|
total: results.length,
|
||||||
|
successful: results.filter((r) => r.success).length,
|
||||||
|
failed: results.filter((r) => !r.success).length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message || "Unknown error" };
|
||||||
|
}
|
||||||
|
});
|
||||||
173
desktop-angular/src/main/modal.html
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data:;"
|
||||||
|
/>
|
||||||
|
<title>Dialog</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #aa1c3a;
|
||||||
|
--text: #ffffff;
|
||||||
|
--btn-bg: #ffffff;
|
||||||
|
--btn-text: #aa1c3a;
|
||||||
|
--btn-sec-bg: rgba(255, 255, 255, 0.1);
|
||||||
|
--btn-sec-text: #ffffff;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--btn-bg);
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--btn-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn.secondary {
|
||||||
|
background: var(--btn-sec-bg);
|
||||||
|
border-color: var(--btn-sec-bg);
|
||||||
|
color: var(--btn-sec-text);
|
||||||
|
}
|
||||||
|
.btn.danger {
|
||||||
|
background: #e53935;
|
||||||
|
border-color: #e53935;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="header">
|
||||||
|
<button class="close" title="Close" id="closeBtn">×</button>
|
||||||
|
<h3 class="title" id="dlgTitle">Dialog</h3>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<p class="message" id="dlgMessage">Message</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer" id="dlgButtons"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const { ipcRenderer } = require("electron");
|
||||||
|
const qs = (s) => document.querySelector(s);
|
||||||
|
const titleEl = qs("#dlgTitle");
|
||||||
|
const msgEl = qs("#dlgMessage");
|
||||||
|
const btnsEl = qs("#dlgButtons");
|
||||||
|
const closeBtn = qs("#closeBtn");
|
||||||
|
|
||||||
|
closeBtn.addEventListener("click", () => {
|
||||||
|
ipcRenderer.send("custom-modal:result", {
|
||||||
|
buttonId: "closed",
|
||||||
|
buttonIndex: -1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcRenderer.on("custom-modal:config", (_evt, cfg) => {
|
||||||
|
// Apply colors if provided
|
||||||
|
if (cfg?.colors) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (cfg.colors.background)
|
||||||
|
root.style.setProperty("--bg", cfg.colors.background);
|
||||||
|
if (cfg.colors.text)
|
||||||
|
root.style.setProperty("--text", cfg.colors.text);
|
||||||
|
if (cfg.colors.buttonBg)
|
||||||
|
root.style.setProperty("--btn-bg", cfg.colors.buttonBg);
|
||||||
|
if (cfg.colors.buttonText)
|
||||||
|
root.style.setProperty("--btn-text", cfg.colors.buttonText);
|
||||||
|
if (cfg.colors.secondaryBg)
|
||||||
|
root.style.setProperty("--btn-sec-bg", cfg.colors.secondaryBg);
|
||||||
|
if (cfg.colors.secondaryText)
|
||||||
|
root.style.setProperty("--btn-sec-text", cfg.colors.secondaryText);
|
||||||
|
}
|
||||||
|
|
||||||
|
titleEl.textContent = cfg?.title || "Dialog";
|
||||||
|
msgEl.textContent = cfg?.message || "";
|
||||||
|
|
||||||
|
// Render buttons
|
||||||
|
btnsEl.innerHTML = "";
|
||||||
|
const buttons = Array.isArray(cfg?.buttons)
|
||||||
|
? cfg.buttons.slice(0, 3)
|
||||||
|
: [{ id: "ok", label: "OK", style: "primary" }];
|
||||||
|
buttons.forEach((b, idx) => {
|
||||||
|
const el = document.createElement("button");
|
||||||
|
el.className =
|
||||||
|
"btn" +
|
||||||
|
(b.style === "danger"
|
||||||
|
? " danger"
|
||||||
|
: b.style === "secondary"
|
||||||
|
? " secondary"
|
||||||
|
: "");
|
||||||
|
if (cfg?.buttonStyles && cfg.buttonStyles[b.id]) {
|
||||||
|
const st = cfg.buttonStyles[b.id];
|
||||||
|
if (st.bg)
|
||||||
|
(el.style.background = st.bg), (el.style.borderColor = st.bg);
|
||||||
|
if (st.text) el.style.color = st.text;
|
||||||
|
}
|
||||||
|
el.textContent = b.label || b.id || "OK";
|
||||||
|
el.addEventListener("click", () => {
|
||||||
|
ipcRenderer.send("custom-modal:result", {
|
||||||
|
buttonId: b.id || `btn${idx}`,
|
||||||
|
buttonIndex: idx,
|
||||||
|
buttonLabel: b.label || "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
btnsEl.appendChild(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
599
desktop-angular/src/main/open-dialog.html
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data:;" />
|
||||||
|
<title>Open File</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #2d3748;
|
||||||
|
--text: #ffffff;
|
||||||
|
--border: rgba(255,255,255,0.2);
|
||||||
|
--hover-bg: rgba(255,255,255,0.1);
|
||||||
|
--selected-bg: rgba(255,255,255,0.2);
|
||||||
|
--button-bg: #aa1c3a;
|
||||||
|
--button-text: #ffffff;
|
||||||
|
--button-hover: #8b1531;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-bar {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--button-bg);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-btn {
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--button-text);
|
||||||
|
border: 1px solid var(--button-bg);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-btn:hover {
|
||||||
|
background: var(--button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.selected {
|
||||||
|
background: var(--selected-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
max-height: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
max-height: 80px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--button-text);
|
||||||
|
border-color: var(--button-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #c82333;
|
||||||
|
border-color: #bd2130;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="dialog-container">
|
||||||
|
<div class="header">
|
||||||
|
<h3 class="title" id="dialogTitle">Open File</h3>
|
||||||
|
<button class="close-btn" id="closeBtn" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="path-bar">
|
||||||
|
<input type="text" class="path-input" id="pathInput" placeholder="Enter path or browse..." />
|
||||||
|
<button class="browse-btn" id="browseBtn">Browse</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-list" id="fileList">
|
||||||
|
<!-- Files will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-section" id="previewSection" style="display: none;">
|
||||||
|
<div class="preview-label">File Preview (first 500 characters)</div>
|
||||||
|
<div class="preview-content" id="previewContent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div class="file-type" id="fileType">All Files (*.*)</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn btn-danger" id="deleteBtn" disabled>Delete</button>
|
||||||
|
<button class="btn btn-secondary" id="cancelBtn">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="openBtn" disabled>Open</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const { ipcRenderer } = require('electron');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let currentPath = '';
|
||||||
|
let selectedFile = null;
|
||||||
|
let config = {};
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
dialogTitle: document.getElementById('dialogTitle'),
|
||||||
|
closeBtn: document.getElementById('closeBtn'),
|
||||||
|
pathInput: document.getElementById('pathInput'),
|
||||||
|
browseBtn: document.getElementById('browseBtn'),
|
||||||
|
fileList: document.getElementById('fileList'),
|
||||||
|
fileType: document.getElementById('fileType'),
|
||||||
|
cancelBtn: document.getElementById('cancelBtn'),
|
||||||
|
openBtn: document.getElementById('openBtn'),
|
||||||
|
deleteBtn: document.getElementById('deleteBtn'),
|
||||||
|
previewSection: document.getElementById('previewSection'),
|
||||||
|
previewContent: document.getElementById('previewContent')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply custom colors if provided
|
||||||
|
function applyColors(colors) {
|
||||||
|
if (!colors) return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (colors.background) root.style.setProperty('--bg', colors.background);
|
||||||
|
if (colors.text) root.style.setProperty('--text', colors.text);
|
||||||
|
if (colors.buttonBg) root.style.setProperty('--button-bg', colors.buttonBg);
|
||||||
|
if (colors.buttonText) root.style.setProperty('--button-text', colors.buttonText);
|
||||||
|
if (colors.border) root.style.setProperty('--border', colors.border);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load directory contents
|
||||||
|
function loadDirectory(dirPath) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPath = dirPath;
|
||||||
|
elements.pathInput.value = dirPath;
|
||||||
|
|
||||||
|
const items = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
const directories = [];
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const fullPath = path.join(dirPath, item.name);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
directories.push({
|
||||||
|
name: item.name,
|
||||||
|
path: fullPath,
|
||||||
|
isDirectory: true,
|
||||||
|
size: '-',
|
||||||
|
icon: '📁'
|
||||||
|
});
|
||||||
|
} else if (item.isFile()) {
|
||||||
|
// Check file extension filter
|
||||||
|
const ext = path.extname(item.name).toLowerCase();
|
||||||
|
const shouldShow = config.filters ?
|
||||||
|
config.filters.some(filter =>
|
||||||
|
filter.extensions.some(extension =>
|
||||||
|
extension.toLowerCase() === ext.toLowerCase() ||
|
||||||
|
extension === '*' ||
|
||||||
|
extension === '.*'
|
||||||
|
)
|
||||||
|
) : true;
|
||||||
|
|
||||||
|
if (shouldShow) {
|
||||||
|
files.push({
|
||||||
|
name: item.name,
|
||||||
|
path: fullPath,
|
||||||
|
isDirectory: false,
|
||||||
|
size: formatFileSize(stat.size),
|
||||||
|
icon: getFileIcon(ext)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort: directories first, then files
|
||||||
|
directories.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const allItems = [...directories, ...files];
|
||||||
|
renderFileList(allItems);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading directory:', error);
|
||||||
|
elements.fileList.innerHTML = '<div style="padding: 20px; text-align: center; opacity: 0.7;">Error loading directory</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render file list
|
||||||
|
function renderFileList(items) {
|
||||||
|
elements.fileList.innerHTML = '';
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
elements.fileList.innerHTML = '<div style="padding: 20px; text-align: center; opacity: 0.7;">No files found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'file-item';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="file-icon">${item.icon}</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<span class="file-name">${item.name}</span>
|
||||||
|
<span class="file-size">${item.size}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
div.addEventListener('click', () => {
|
||||||
|
// Remove previous selection
|
||||||
|
document.querySelectorAll('.file-item.selected').forEach(el => {
|
||||||
|
el.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
div.classList.add('selected');
|
||||||
|
selectedFile = item;
|
||||||
|
|
||||||
|
// Enable/disable buttons
|
||||||
|
elements.openBtn.disabled = item.isDirectory;
|
||||||
|
elements.deleteBtn.disabled = item.isDirectory;
|
||||||
|
|
||||||
|
// Show preview for files
|
||||||
|
if (!item.isDirectory) {
|
||||||
|
showFilePreview(item.path);
|
||||||
|
} else {
|
||||||
|
elements.previewSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a directory, navigate into it
|
||||||
|
if (item.isDirectory) {
|
||||||
|
loadDirectory(item.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.fileList.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format file size
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === '-') return '-';
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file icon based on extension
|
||||||
|
function getFileIcon(ext) {
|
||||||
|
const iconMap = {
|
||||||
|
'.yaml': '📄',
|
||||||
|
'.yml': '📄',
|
||||||
|
'.txt': '📄',
|
||||||
|
'.json': '📄',
|
||||||
|
'.js': '📄',
|
||||||
|
'.ts': '📄',
|
||||||
|
'.html': '📄',
|
||||||
|
'.css': '📄',
|
||||||
|
'.md': '📄',
|
||||||
|
'.xml': '📄',
|
||||||
|
'.log': '📄'
|
||||||
|
};
|
||||||
|
return iconMap[ext.toLowerCase()] || '📄';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show file preview
|
||||||
|
function showFilePreview(filePath) {
|
||||||
|
if (!filePath || !fs.existsSync(filePath)) {
|
||||||
|
elements.previewSection.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
elements.previewSection.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show preview for text files (under 1MB)
|
||||||
|
if (stats.size > 1024 * 1024) {
|
||||||
|
elements.previewSection.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const preview = content.length > 500 ? content.substring(0, 500) + '...' : content;
|
||||||
|
|
||||||
|
elements.previewContent.textContent = preview;
|
||||||
|
elements.previewSection.style.display = 'block';
|
||||||
|
} catch (error) {
|
||||||
|
elements.previewSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
elements.closeBtn.addEventListener('click', () => {
|
||||||
|
ipcRenderer.send('file-dialog:result', { canceled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.cancelBtn.addEventListener('click', () => {
|
||||||
|
ipcRenderer.send('file-dialog:result', { canceled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.openBtn.addEventListener('click', () => {
|
||||||
|
if (selectedFile && !selectedFile.isDirectory) {
|
||||||
|
ipcRenderer.send('file-dialog:result', {
|
||||||
|
canceled: false,
|
||||||
|
filePath: selectedFile.path,
|
||||||
|
content: fs.readFileSync(selectedFile.path, 'utf-8')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!selectedFile || selectedFile.isDirectory) return;
|
||||||
|
|
||||||
|
const confirmDelete = confirm(`Are you sure you want to delete "${selectedFile.name}"?`);
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(selectedFile.path);
|
||||||
|
// Reload directory to refresh file list
|
||||||
|
loadDirectory(currentPath);
|
||||||
|
// Clear selection
|
||||||
|
selectedFile = null;
|
||||||
|
elements.openBtn.disabled = true;
|
||||||
|
elements.deleteBtn.disabled = true;
|
||||||
|
elements.previewSection.style.display = 'none';
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error deleting file: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.pathInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const newPath = elements.pathInput.value.trim();
|
||||||
|
if (newPath && fs.existsSync(newPath)) {
|
||||||
|
loadDirectory(newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.browseBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
// Request main process to show directory picker
|
||||||
|
const result = await ipcRenderer.invoke('dialog:showDirectoryPicker', {
|
||||||
|
title: 'Select Directory',
|
||||||
|
defaultPath: currentPath
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.filePaths && result.filePaths.length > 0) {
|
||||||
|
currentPath = result.filePaths[0];
|
||||||
|
elements.pathInput.value = currentPath;
|
||||||
|
loadDirectory(currentPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening directory picker:', error);
|
||||||
|
// Fallback to prompt
|
||||||
|
const newPath = prompt('Enter directory path:', currentPath);
|
||||||
|
if (newPath && fs.existsSync(newPath) && fs.statSync(newPath).isDirectory()) {
|
||||||
|
currentPath = newPath;
|
||||||
|
elements.pathInput.value = currentPath;
|
||||||
|
loadDirectory(currentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
ipcRenderer.on('file-dialog:config', (event, dialogConfig) => {
|
||||||
|
config = dialogConfig || {};
|
||||||
|
|
||||||
|
// Apply custom colors
|
||||||
|
applyColors(config.colors);
|
||||||
|
|
||||||
|
// Set title and message
|
||||||
|
elements.dialogTitle.textContent = config.title || 'Open File';
|
||||||
|
|
||||||
|
// Set file type filter text
|
||||||
|
if (config.filters && config.filters.length > 0) {
|
||||||
|
const filterText = config.filters.map(f => `${f.name} (${f.extensions.join(', ')})`).join('; ');
|
||||||
|
elements.fileType.textContent = filterText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial directory
|
||||||
|
const initialPath = config.defaultPath || os.homedir();
|
||||||
|
loadDirectory(initialPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle double-click to open
|
||||||
|
elements.fileList.addEventListener('dblclick', (e) => {
|
||||||
|
const fileItem = e.target.closest('.file-item');
|
||||||
|
if (fileItem && selectedFile && !selectedFile.isDirectory) {
|
||||||
|
elements.openBtn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
605
desktop-angular/src/main/save-dialog.html
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data:;" />
|
||||||
|
<title>Save File</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #2d3748;
|
||||||
|
--text: #ffffff;
|
||||||
|
--border: rgba(255,255,255,0.2);
|
||||||
|
--hover-bg: rgba(255,255,255,0.1);
|
||||||
|
--selected-bg: rgba(255,255,255,0.2);
|
||||||
|
--button-bg: #aa1c3a;
|
||||||
|
--button-text: #ffffff;
|
||||||
|
--button-hover: #8b1531;
|
||||||
|
--input-bg: rgba(255,255,255,0.1);
|
||||||
|
--input-border: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--button-bg);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-section {
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--button-bg);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-btn {
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--button-text);
|
||||||
|
border: 1px solid var(--button-bg);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-btn:hover {
|
||||||
|
background: var(--button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type-info {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--button-text);
|
||||||
|
border-color: var(--button-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-section {
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-top: 16px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-label {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="dialog-container">
|
||||||
|
<div class="header">
|
||||||
|
<h3 class="title" id="dialogTitle">Save File</h3>
|
||||||
|
<button class="close-btn" id="closeBtn" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="fileName">File Name</label>
|
||||||
|
<input type="text" class="form-input" id="fileName" placeholder="Enter file name..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="path-section">
|
||||||
|
<div class="path-row">
|
||||||
|
<input type="text" class="path-input" id="pathInput" placeholder="Enter directory path..." readonly />
|
||||||
|
<button class="browse-btn" id="browseBtn">Browse</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-type" id="fileType">All Files (*.*)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="files-section">
|
||||||
|
<div class="files-label">Files in current directory</div>
|
||||||
|
<div class="files-list" id="filesList">
|
||||||
|
<!-- Files will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div class="file-type-info" id="fileTypeInfo">All Files (*.*)</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn btn-secondary" id="cancelBtn">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="saveBtn" disabled>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const { ipcRenderer } = require('electron');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
let currentPath = '';
|
||||||
|
let config = {};
|
||||||
|
let content = '';
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
dialogTitle: document.getElementById('dialogTitle'),
|
||||||
|
closeBtn: document.getElementById('closeBtn'),
|
||||||
|
fileName: document.getElementById('fileName'),
|
||||||
|
pathInput: document.getElementById('pathInput'),
|
||||||
|
browseBtn: document.getElementById('browseBtn'),
|
||||||
|
fileType: document.getElementById('fileType'),
|
||||||
|
fileTypeInfo: document.getElementById('fileTypeInfo'),
|
||||||
|
cancelBtn: document.getElementById('cancelBtn'),
|
||||||
|
saveBtn: document.getElementById('saveBtn'),
|
||||||
|
filesList: document.getElementById('filesList')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply custom colors if provided
|
||||||
|
function applyColors(colors) {
|
||||||
|
if (!colors) return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (colors.background) root.style.setProperty('--bg', colors.background);
|
||||||
|
if (colors.text) root.style.setProperty('--text', colors.text);
|
||||||
|
if (colors.buttonBg) root.style.setProperty('--button-bg', colors.buttonBg);
|
||||||
|
if (colors.buttonText) root.style.setProperty('--button-text', colors.buttonText);
|
||||||
|
if (colors.border) root.style.setProperty('--border', colors.border);
|
||||||
|
if (colors.inputBg) root.style.setProperty('--input-bg', colors.inputBg);
|
||||||
|
if (colors.inputBorder) root.style.setProperty('--input-border', colors.inputBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update save button state
|
||||||
|
function updateSaveButton() {
|
||||||
|
const fileName = elements.fileName.value.trim();
|
||||||
|
const hasPath = currentPath && fs.existsSync(currentPath);
|
||||||
|
elements.saveBtn.disabled = !fileName || !hasPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update file path display
|
||||||
|
function updatePathDisplay() {
|
||||||
|
elements.pathInput.value = currentPath;
|
||||||
|
updateSaveButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Get file extension from filename
|
||||||
|
function getFileExtension(filename) {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
return ext || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format file size
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate filename
|
||||||
|
function isValidFilename(filename) {
|
||||||
|
if (!filename || filename.trim() === '') return false;
|
||||||
|
|
||||||
|
// Check for invalid characters
|
||||||
|
const invalidChars = /[<>:"/\\|?*]/;
|
||||||
|
if (invalidChars.test(filename)) return false;
|
||||||
|
|
||||||
|
// Check for reserved names (Windows)
|
||||||
|
const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
|
||||||
|
const nameWithoutExt = path.parse(filename).name.toUpperCase();
|
||||||
|
if (reservedNames.includes(nameWithoutExt)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show files in current directory
|
||||||
|
function showFilesInDirectory() {
|
||||||
|
console.log('showFilesInDirectory called with currentPath:', currentPath);
|
||||||
|
|
||||||
|
if (!currentPath) {
|
||||||
|
elements.filesList.innerHTML = '<div class="file-item">No directory selected</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(currentPath)) {
|
||||||
|
console.error('Directory does not exist:', currentPath);
|
||||||
|
elements.filesList.innerHTML = '<div class="file-item">Directory does not exist: ' + currentPath + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(currentPath);
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
console.error('Path is not a directory:', currentPath);
|
||||||
|
elements.filesList.innerHTML = '<div class="file-item">Path is not a directory: ' + currentPath + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(currentPath);
|
||||||
|
console.log('Files found:', files);
|
||||||
|
|
||||||
|
const fileStats = files.map(file => {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(currentPath, file);
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
return {
|
||||||
|
name: file,
|
||||||
|
path: filePath,
|
||||||
|
isDirectory: stats.isDirectory(),
|
||||||
|
size: stats.size
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting stats for file:', file, error);
|
||||||
|
return {
|
||||||
|
name: file,
|
||||||
|
path: path.join(currentPath, file),
|
||||||
|
isDirectory: false,
|
||||||
|
size: 0,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}).filter(file => file); // Remove any null/undefined entries
|
||||||
|
|
||||||
|
// Show all files without filtering
|
||||||
|
const filteredFiles = fileStats;
|
||||||
|
|
||||||
|
// Sort: directories first, then files
|
||||||
|
filteredFiles.sort((a, b) => {
|
||||||
|
if (a.isDirectory && !b.isDirectory) return -1;
|
||||||
|
if (!a.isDirectory && b.isDirectory) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.filesList.innerHTML = '';
|
||||||
|
console.log ('filteredFiles', filteredFiles);
|
||||||
|
|
||||||
|
if (filteredFiles.length === 0) {
|
||||||
|
elements.filesList.innerHTML = '<div class="file-item">No files found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredFiles.forEach(file => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'file-item';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="file-icon">${file.isDirectory ? '📁' : '📄'}</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<span class="file-name">${file.name}</span>
|
||||||
|
<span class="file-size">${file.isDirectory ? 'DIR' : formatFileSize(file.size)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
div.addEventListener('click', () => {
|
||||||
|
if (file.isDirectory) {
|
||||||
|
currentPath = file.path;
|
||||||
|
elements.pathInput.value = currentPath;
|
||||||
|
showFilesInDirectory();
|
||||||
|
} else {
|
||||||
|
// Select file and update filename input
|
||||||
|
elements.fileName.value = file.name;
|
||||||
|
updateSaveButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.filesList.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading directory:', error);
|
||||||
|
elements.filesList.innerHTML = '<div class="file-item">Error reading directory: ' + error.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
elements.closeBtn.addEventListener('click', () => {
|
||||||
|
ipcRenderer.send('save-dialog:result', { canceled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.cancelBtn.addEventListener('click', () => {
|
||||||
|
ipcRenderer.send('save-dialog:result', { canceled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.saveBtn.addEventListener('click', () => {
|
||||||
|
const fileName = elements.fileName.value.trim();
|
||||||
|
|
||||||
|
if (!isValidFilename(fileName)) {
|
||||||
|
alert('Invalid filename. Please use valid characters and avoid reserved names.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(currentPath, fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, content, 'utf-8');
|
||||||
|
ipcRenderer.send('save-dialog:result', {
|
||||||
|
canceled: false,
|
||||||
|
filePath: filePath
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error saving file: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.fileName.addEventListener('input', updateSaveButton);
|
||||||
|
elements.fileName.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter' && !elements.saveBtn.disabled) {
|
||||||
|
elements.saveBtn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.browseBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
// Request main process to show directory picker
|
||||||
|
const result = await ipcRenderer.invoke('dialog:showDirectoryPicker', {
|
||||||
|
title: 'Select Directory',
|
||||||
|
defaultPath: currentPath
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.filePaths && result.filePaths.length > 0) {
|
||||||
|
currentPath = result.filePaths[0];
|
||||||
|
updatePathDisplay();
|
||||||
|
showFilesInDirectory();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening directory picker:', error);
|
||||||
|
// Fallback to prompt
|
||||||
|
const newPath = prompt('Enter directory path:', currentPath);
|
||||||
|
if (newPath && fs.existsSync(newPath) && fs.statSync(newPath).isDirectory()) {
|
||||||
|
currentPath = newPath;
|
||||||
|
updatePathDisplay();
|
||||||
|
showFilesInDirectory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
ipcRenderer.on('save-dialog:config', (event, dialogConfig) => {
|
||||||
|
config = dialogConfig || {};
|
||||||
|
content = config.content || '';
|
||||||
|
|
||||||
|
// Apply custom colors
|
||||||
|
applyColors(config.colors);
|
||||||
|
|
||||||
|
// Set title
|
||||||
|
elements.dialogTitle.textContent = config.title || 'Save File';
|
||||||
|
|
||||||
|
// Set default path
|
||||||
|
currentPath = config.defaultPath || os.homedir();
|
||||||
|
updatePathDisplay();
|
||||||
|
|
||||||
|
// Set default filename
|
||||||
|
if (config.suggestedName) {
|
||||||
|
elements.fileName.value = config.suggestedName;
|
||||||
|
updateSaveButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set file type filter text
|
||||||
|
if (config.filters && config.filters.length > 0) {
|
||||||
|
const filterText = config.filters.map(f => `${f.name} (${f.extensions.join(', ')})`).join('; ');
|
||||||
|
elements.fileType.textContent = filterText;
|
||||||
|
elements.fileTypeInfo.textContent = filterText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show files in directory
|
||||||
|
showFilesInDirectory();
|
||||||
|
|
||||||
|
// Focus filename input
|
||||||
|
elements.fileName.focus();
|
||||||
|
elements.fileName.select();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
desktop-angular/src/preload/preload.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// desktop-angular/src/preload/preload.js
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('env', { isElectron: true });
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
getConfig: (key) => ipcRenderer.invoke('config:get', key),
|
||||||
|
setConfig: (key, value) => ipcRenderer.invoke('config:set', key, value),
|
||||||
|
getAllConfig: () => ipcRenderer.invoke('config:getAll'),
|
||||||
|
setAllConfig: (cfg) => ipcRenderer.invoke('config:setAll', cfg),
|
||||||
|
closeSettings: () => ipcRenderer.invoke('settings:close'),
|
||||||
|
|
||||||
|
openFile: () => ipcRenderer.invoke('file:open'),
|
||||||
|
saveAs: (payload) => ipcRenderer.invoke('file:saveAs', payload),
|
||||||
|
saveSilent: (payload) => ipcRenderer.invoke('file:saveSilent', payload),
|
||||||
|
revealInFolder: (p) => ipcRenderer.invoke('os:revealInFolder', p),
|
||||||
|
|
||||||
|
localKnock: (payload) => ipcRenderer.invoke('knock:local', payload),
|
||||||
|
getNetworkInterfaces: () => ipcRenderer.invoke('network:interfaces'),
|
||||||
|
testConnection: (payload) => ipcRenderer.invoke('network:test-connection', payload),
|
||||||
|
|
||||||
|
// Custom electron-powered modal
|
||||||
|
showNativeModal: (config) => ipcRenderer.invoke('dialog:custom', config),
|
||||||
|
|
||||||
|
// Custom file dialog
|
||||||
|
showCustomFileDialog: (config) => ipcRenderer.invoke('dialog:customFile', config),
|
||||||
|
|
||||||
|
// Custom save dialog
|
||||||
|
showCustomSaveDialog: (config) => ipcRenderer.invoke('dialog:customSave', config),
|
||||||
|
|
||||||
|
// Config files management
|
||||||
|
listConfigFiles: () => ipcRenderer.invoke('config:listFiles'),
|
||||||
|
loadConfigFile: (fileName) => ipcRenderer.invoke('config:loadFile', fileName),
|
||||||
|
|
||||||
|
// App lifecycle
|
||||||
|
checkUnsavedChanges: () => ipcRenderer.invoke('app:checkUnsavedChanges'),
|
||||||
|
|
||||||
|
// YAML dirty state sync
|
||||||
|
setYamlDirty: (isDirty) => ipcRenderer.invoke('app:setYamlDirty', isDirty)
|
||||||
|
});
|
||||||
990
desktop/DEVELOPMENT.md
Normal file
@@ -0,0 +1,990 @@
|
|||||||
|
# Руководство по разработке Knocker Desktop
|
||||||
|
|
||||||
|
## 🔍 Подробное описание архитектуры
|
||||||
|
|
||||||
|
### Архитектура Electron приложения
|
||||||
|
|
||||||
|
``` text
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ MAIN PROCESS │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ src/main/main.js │ │
|
||||||
|
│ │ • Управление жизненным циклом приложения │ │
|
||||||
|
│ │ • Создание и управление окнами │ │
|
||||||
|
│ │ • Доступ к Node.js API (fs, dialog, shell) │ │
|
||||||
|
│ │ • IPC обработчики для файловых операций │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ IPC (Inter-Process Communication)
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ RENDERER PROCESS │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ src/renderer/ │ │
|
||||||
|
│ │ • HTML/CSS/JS интерфейс │ │
|
||||||
|
│ │ • Взаимодействие с пользователем │ │
|
||||||
|
│ │ • HTTP запросы к API │ │
|
||||||
|
│ │ • Ограниченный доступ к системе │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ contextBridge
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ PRELOAD SCRIPT │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ src/preload/preload.js │ │
|
||||||
|
│ │ • Безопасный мост между main и renderer │ │
|
||||||
|
│ │ • Доступ к Node.js API │ │
|
||||||
|
│ │ • Экспорт API через window.api │ │
|
||||||
|
│ │ • Изоляция от прямого доступа к Node.js │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Детальное объяснение процессов
|
||||||
|
|
||||||
|
#### 1. Main Process (Основной процесс)
|
||||||
|
|
||||||
|
**Роль**: Ядро приложения, управляет всей жизнью приложения.
|
||||||
|
|
||||||
|
**Возможности**:
|
||||||
|
|
||||||
|
- Создание и управление окнами
|
||||||
|
- Доступ к Node.js API (файловая система, диалоги, системные функции)
|
||||||
|
- Обработка системных событий (закрытие приложения, фокус окон)
|
||||||
|
- IPC сервер для связи с renderer процессами
|
||||||
|
|
||||||
|
**Код в `src/main/main.js`**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
|
||||||
|
|
||||||
|
// Создание главного окна с настройками безопасности
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1100,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/preload.js'),
|
||||||
|
contextIsolation: true, // КРИТИЧНО: изолирует контекст
|
||||||
|
nodeIntegration: false, // КРИТИЧНО: отключает прямой доступ к Node.js
|
||||||
|
sandbox: false // Позволяет preload работать
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC обработчики - "серверная часть" для renderer
|
||||||
|
ipcMain.handle('file:open', async () => {
|
||||||
|
// Безопасная работа с файлами через main процесс
|
||||||
|
const res = await dialog.showOpenDialog({
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [{ name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] }]
|
||||||
|
});
|
||||||
|
// Возвращаем данные в renderer процесс
|
||||||
|
return { canceled: res.canceled, filePath: res.filePaths[0], content: fs.readFileSync(res.filePaths[0], 'utf-8') };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Renderer Process (Процесс рендеринга)
|
||||||
|
|
||||||
|
**Роль**: Отображение пользовательского интерфейса, взаимодействие с пользователем.
|
||||||
|
|
||||||
|
**Ограничения**:
|
||||||
|
|
||||||
|
- Нет прямого доступа к Node.js API
|
||||||
|
- Работает как обычная веб-страница
|
||||||
|
- Изолирован от файловой системы
|
||||||
|
- Может делать HTTP запросы
|
||||||
|
|
||||||
|
**Код в `src/renderer/renderer.js`**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Используем безопасный API из preload
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Обработчики UI событий
|
||||||
|
document.getElementById('openFile').addEventListener('click', async () => {
|
||||||
|
// Вызов через contextBridge API
|
||||||
|
const result = await window.api.openFile();
|
||||||
|
if (!result.canceled) {
|
||||||
|
// Обновляем UI с данными файла
|
||||||
|
document.getElementById('configYAML').value = result.content;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTTP запросы к backend API
|
||||||
|
document.getElementById('execute').addEventListener('click', async () => {
|
||||||
|
const response = await fetch('http://localhost:8080/api/v1/knock-actions/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...basicAuthHeader(password) },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Preload Script (Preload скрипт)
|
||||||
|
|
||||||
|
**Роль**: Безопасный мост между main и renderer процессами.
|
||||||
|
|
||||||
|
**Особенности**:
|
||||||
|
|
||||||
|
- Выполняется в renderer процессе
|
||||||
|
- Имеет доступ к Node.js API
|
||||||
|
- Изолирован от глобального контекста renderer
|
||||||
|
- Создает безопасный API через `contextBridge`
|
||||||
|
|
||||||
|
**Код в `src/preload/preload.js`**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
// Создаем безопасный API для renderer процесса
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
// Файловые операции
|
||||||
|
openFile: () => ipcRenderer.invoke('file:open'),
|
||||||
|
saveAs: (payload) => ipcRenderer.invoke('file:saveAs', payload),
|
||||||
|
saveToPath: (payload) => ipcRenderer.invoke('file:saveToPath', payload),
|
||||||
|
revealInFolder: (filePath) => ipcRenderer.invoke('os:revealInFolder', filePath)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Renderer процесс получает доступ к window.api
|
||||||
|
// Но НЕ имеет прямого доступа к require, fs, dialog и т.д.
|
||||||
|
```
|
||||||
|
|
||||||
|
### IPC (Inter-Process Communication) - Связь между процессами
|
||||||
|
|
||||||
|
#### Как работает IPC
|
||||||
|
|
||||||
|
``` text
|
||||||
|
┌─────────────┐ IPC Message ┌─────────────┐
|
||||||
|
│ Renderer │ ────────────────> │ Main │
|
||||||
|
│ Process │ │ Process │
|
||||||
|
│ │ <──────────────── │ │
|
||||||
|
└─────────────┘ IPC Response └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Шаг 1**: Renderer процесс вызывает `window.api.openFile()`
|
||||||
|
**Шаг 2**: Preload скрипт отправляет IPC сообщение `'file:open'` в main процесс
|
||||||
|
**Шаг 3**: Main процесс обрабатывает сообщение и выполняет файловую операцию
|
||||||
|
**Шаг 4**: Main процесс возвращает результат через IPC
|
||||||
|
**Шаг 5**: Preload скрипт получает результат и возвращает его renderer процессу
|
||||||
|
|
||||||
|
#### Типы IPC сообщений в приложении
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Main процесс (обработчики)
|
||||||
|
ipcMain.handle('file:open', handler); // Открытие файла
|
||||||
|
ipcMain.handle('file:saveAs', handler); // Сохранение файла
|
||||||
|
ipcMain.handle('file:saveToPath', handler); // Сохранение по пути
|
||||||
|
ipcMain.handle('os:revealInFolder', handler); // Показать в проводнике
|
||||||
|
|
||||||
|
// Preload скрипт (клиент)
|
||||||
|
ipcRenderer.invoke('file:open'); // Отправка запроса
|
||||||
|
ipcRenderer.invoke('file:saveAs', payload); // Отправка с данными
|
||||||
|
```
|
||||||
|
|
||||||
|
### Безопасность в Electron
|
||||||
|
|
||||||
|
#### Принципы безопасности
|
||||||
|
|
||||||
|
1. **Context Isolation** - изоляция контекста
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true // Renderer не может получить доступ к Node.js
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Node Integration** - отключение интеграции Node.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false // Отключаем прямой доступ к require()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Sandbox** - песочница
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
webPreferences: {
|
||||||
|
sandbox: false // Позволяем preload работать
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Почему такая архитектура?
|
||||||
|
|
||||||
|
**Проблема**: Renderer процесс работает с ненадежным контентом (HTML/JS от пользователя).
|
||||||
|
|
||||||
|
**Решение**: Изолируем renderer от Node.js API, но предоставляем безопасный доступ через preload.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ❌ НЕБЕЗОПАСНО (если включить nodeIntegration: true)
|
||||||
|
// В renderer процессе:
|
||||||
|
const fs = require('fs');
|
||||||
|
fs.readFileSync('/etc/passwd'); // Может прочитать системные файлы!
|
||||||
|
|
||||||
|
// ✅ БЕЗОПАСНО (через contextBridge)
|
||||||
|
// В renderer процессе:
|
||||||
|
const result = await window.api.openFile(); // Только разрешенные операции
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Функциональность приложения
|
||||||
|
|
||||||
|
### Режимы работы
|
||||||
|
|
||||||
|
#### 1. Inline режим
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Простые поля для быстрого ввода
|
||||||
|
const formData = {
|
||||||
|
password: 'user_password',
|
||||||
|
targets: 'tcp:127.0.0.1:22;tcp:192.168.1.1:80',
|
||||||
|
delay: '1s',
|
||||||
|
verbose: true,
|
||||||
|
waitConnection: false,
|
||||||
|
gateway: 'optional_gateway'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. YAML режим
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Полная YAML конфигурация
|
||||||
|
targets:
|
||||||
|
- protocol: tcp
|
||||||
|
host: 127.0.0.1
|
||||||
|
ports: [22, 80]
|
||||||
|
wait_connection: true
|
||||||
|
- protocol: udp
|
||||||
|
host: 192.168.1.1
|
||||||
|
ports: [53]
|
||||||
|
delay: 1s
|
||||||
|
path: /etc/knocker/config.yaml # Путь на сервере
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Form режим
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Табличная форма для добавления целей
|
||||||
|
const targets = [
|
||||||
|
{ protocol: 'tcp', host: '127.0.0.1', port: 22, gateway: '' },
|
||||||
|
{ protocol: 'udp', host: '192.168.1.1', port: 53, gateway: 'gw1' }
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Файловые операции
|
||||||
|
|
||||||
|
#### Открытие файлов
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Main процесс
|
||||||
|
ipcMain.handle('file:open', async () => {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [{ name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled) return { canceled: true };
|
||||||
|
|
||||||
|
const filePath = result.filePaths[0];
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { canceled: false, filePath, content };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Сохранение файлов
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Main процесс
|
||||||
|
ipcMain.handle('file:saveAs', async (event, payload) => {
|
||||||
|
const result = await dialog.showSaveDialog({
|
||||||
|
defaultPath: payload.suggestedName || 'config.yaml',
|
||||||
|
filters: [{ name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.filePath) return { canceled: true };
|
||||||
|
|
||||||
|
fs.writeFileSync(result.filePath, payload.content, 'utf-8');
|
||||||
|
return { canceled: false, filePath: result.filePath };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP API интеграция
|
||||||
|
|
||||||
|
#### Basic Authentication
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function basicAuthHeader(password) {
|
||||||
|
const token = btoa(`knocker:${password}`);
|
||||||
|
return { Authorization: `Basic ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Использование в запросах
|
||||||
|
const response = await fetch('http://localhost:8080/api/v1/knock-actions/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...basicAuthHeader(password)
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API endpoints
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const apiEndpoints = {
|
||||||
|
execute: '/api/v1/knock-actions/execute',
|
||||||
|
encrypt: '/api/v1/knock-actions/encrypt',
|
||||||
|
decrypt: '/api/v1/knock-actions/decrypt',
|
||||||
|
encryptFile: '/api/v1/knock-actions/encrypt-file'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### YAML обработка
|
||||||
|
|
||||||
|
#### Извлечение пути из YAML
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function extractPathFromYaml(text) {
|
||||||
|
try {
|
||||||
|
const doc = yaml.load(text);
|
||||||
|
if (doc && typeof doc === 'object' && typeof doc.path === 'string') {
|
||||||
|
return doc.path;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse YAML:', e);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Обновление пути в YAML
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function patchYamlPath(text, newPath) {
|
||||||
|
try {
|
||||||
|
const doc = text.trim() ? yaml.load(text) : {};
|
||||||
|
if (doc && typeof doc === 'object') {
|
||||||
|
doc.path = newPath || '';
|
||||||
|
return yaml.dump(doc, { lineWidth: 120 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to update YAML path:', e);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Конвертация между режимами
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Inline → YAML
|
||||||
|
function convertInlineToYaml(targetsStr, delay, waitConnection) {
|
||||||
|
const entries = targetsStr.split(';').filter(Boolean);
|
||||||
|
const config = {
|
||||||
|
targets: entries.map(entry => {
|
||||||
|
const [protocol, host, port] = entry.split(':');
|
||||||
|
return {
|
||||||
|
protocol: protocol || 'tcp',
|
||||||
|
host: host || '127.0.0.1',
|
||||||
|
ports: [parseInt(port) || 22],
|
||||||
|
wait_connection: waitConnection
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
delay: delay || '1s'
|
||||||
|
};
|
||||||
|
return yaml.dump(config, { lineWidth: 120 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAML → Inline
|
||||||
|
function convertYamlToInline(yamlText) {
|
||||||
|
const config = yaml.load(yamlText) || {};
|
||||||
|
const targets = [];
|
||||||
|
|
||||||
|
(config.targets || []).forEach(target => {
|
||||||
|
const protocol = target.protocol || 'tcp';
|
||||||
|
const host = target.host || '127.0.0.1';
|
||||||
|
const ports = target.ports || [target.port] || [22];
|
||||||
|
|
||||||
|
ports.forEach(port => {
|
||||||
|
targets.push(`${protocol}:${host}:${port}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
targets: targets.join(';'),
|
||||||
|
delay: config.delay || '1s',
|
||||||
|
waitConnection: !!(config.targets?.[0]?.wait_connection)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Разработка и отладка
|
||||||
|
|
||||||
|
### Настройка среды разработки
|
||||||
|
|
||||||
|
#### 1. Структура проекта
|
||||||
|
|
||||||
|
``` text
|
||||||
|
desktop/
|
||||||
|
├── src/
|
||||||
|
│ ├── main/
|
||||||
|
│ │ ├── main.js # Основной процесс (CommonJS)
|
||||||
|
│ │ └── main.ts # TypeScript версия (опционально)
|
||||||
|
│ ├── preload/
|
||||||
|
│ │ ├── preload.js # Preload скрипт (CommonJS)
|
||||||
|
│ │ └── preload.ts # TypeScript версия (опционально)
|
||||||
|
│ └── renderer/
|
||||||
|
│ ├── index.html # HTML разметка
|
||||||
|
│ ├── styles.css # Стили
|
||||||
|
│ ├── renderer.js # UI логика (ванильный JS)
|
||||||
|
│ └── renderer.ts # TypeScript версия (опционально)
|
||||||
|
├── assets/ # Иконки для сборки
|
||||||
|
├── dist/ # Собранные приложения
|
||||||
|
├── package.json # Конфигурация
|
||||||
|
├── README.md # Основная документация
|
||||||
|
└── DEVELOPMENT.md # Это руководство
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Зависимости
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^28.3.3", // Electron runtime
|
||||||
|
"electron-builder": "^26.0.12" // Сборка и пакетирование
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.12.2", // HTTP клиент (не используется в финальной версии)
|
||||||
|
"js-yaml": "^4.1.0" // YAML парсер
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Отладка
|
||||||
|
|
||||||
|
#### DevTools
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В main.js автоматически открываются DevTools
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Логирование
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Main процесс - логи в терминале
|
||||||
|
console.log('Main process:', data);
|
||||||
|
|
||||||
|
// Renderer процесс - логи в DevTools Console
|
||||||
|
console.log('Renderer process:', data);
|
||||||
|
|
||||||
|
// IPC отладка в preload
|
||||||
|
const originalInvoke = ipcRenderer.invoke;
|
||||||
|
ipcRenderer.invoke = function(channel, ...args) {
|
||||||
|
console.log(`IPC Request: ${channel}`, args);
|
||||||
|
return originalInvoke.call(this, channel, ...args).then(result => {
|
||||||
|
console.log(`IPC Response: ${channel}`, result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Отладка файловых операций
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В main.js добавить логирование
|
||||||
|
ipcMain.handle('file:open', async () => {
|
||||||
|
console.log('Opening file dialog...');
|
||||||
|
const result = await dialog.showOpenDialog({...});
|
||||||
|
console.log('Dialog result:', result);
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
#### Локальное тестирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск в режиме разработки
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Проверка функциональности:
|
||||||
|
# 1. Открытие файлов
|
||||||
|
# 2. Сохранение файлов
|
||||||
|
# 3. HTTP запросы к API
|
||||||
|
# 4. Переключение между режимами
|
||||||
|
# 5. Конвертация YAML ↔ Inline
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Тестирование сборки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Упаковка без установщика
|
||||||
|
npm run pack
|
||||||
|
|
||||||
|
# Полная сборка
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Проверка на разных платформах
|
||||||
|
npm run build:win
|
||||||
|
npm run build:linux
|
||||||
|
npm run build:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Сборка и распространение
|
||||||
|
|
||||||
|
### Electron Builder конфигурация
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"appId": "com.knocker.desktop", // Уникальный ID приложения
|
||||||
|
"productName": "Knocker Desktop", // Имя продукта
|
||||||
|
"directories": {
|
||||||
|
"output": "dist" // Папка для сборки
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/**/*", // Исходный код
|
||||||
|
"node_modules/**/*" // Зависимости
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": "nsis", // Windows installer
|
||||||
|
"icon": "assets/icon.ico" // Иконка Windows
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage", // Linux portable app
|
||||||
|
"icon": "assets/icon.png" // Иконка Linux
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg", // macOS disk image
|
||||||
|
"icon": "assets/icon.icns" // Иконка macOS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Типы сборки
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
- **NSIS** - установщик с мастером установки
|
||||||
|
- **Portable** - портативная версия
|
||||||
|
- **Squirrel** - автообновления
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
|
||||||
|
- **AppImage** - портативное приложение
|
||||||
|
- **deb** - пакет для Debian/Ubuntu
|
||||||
|
- **rpm** - пакет для Red Hat/Fedora
|
||||||
|
- **tar.xz** - архив
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
- **dmg** - образ диска
|
||||||
|
- **pkg** - установщик пакета
|
||||||
|
- **mas** - Mac App Store
|
||||||
|
|
||||||
|
### Команды сборки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка для текущей платформы
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Сборка для конкретных платформ
|
||||||
|
npm run build:win # Windows (NSIS)
|
||||||
|
npm run build:linux # Linux (AppImage)
|
||||||
|
npm run build:mac # macOS (DMG)
|
||||||
|
|
||||||
|
# Упаковка без установщика (для тестирования)
|
||||||
|
npm run pack
|
||||||
|
|
||||||
|
# Сборка без публикации
|
||||||
|
npm run dist
|
||||||
|
|
||||||
|
# Публикация (если настроено)
|
||||||
|
npm run publish
|
||||||
|
```
|
||||||
|
|
||||||
|
### Иконки и ресурсы
|
||||||
|
|
||||||
|
#### Требования к иконкам
|
||||||
|
|
||||||
|
``` text
|
||||||
|
assets/
|
||||||
|
├── icon.ico # Windows: 256x256, ICO формат
|
||||||
|
├── icon.png # Linux: 512x512, PNG формат
|
||||||
|
└── icon.icns # macOS: 512x512, ICNS формат
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Создание иконок
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Из PNG в ICO (Windows)
|
||||||
|
convert icon.png -resize 256x256 icon.ico
|
||||||
|
|
||||||
|
# Из PNG в ICNS (macOS)
|
||||||
|
iconutil -c icns icon.iconset
|
||||||
|
```
|
||||||
|
|
||||||
|
### Автоматизация сборки
|
||||||
|
|
||||||
|
#### GitHub Actions пример
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build Electron App
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [windows-latest, macos-latest, ubuntu-latest]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.os }}
|
||||||
|
path: dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Производительность и оптимизация
|
||||||
|
|
||||||
|
### Оптимизация размера приложения
|
||||||
|
|
||||||
|
#### Исключение ненужных файлов
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"files": [
|
||||||
|
"src/**/*",
|
||||||
|
"node_modules/**/*"
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/electron/**/*" // Исключаем Electron из asar
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tree shaking
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Используем только нужные части библиотек
|
||||||
|
import { load, dump } from 'js-yaml'; // Вместо import * as yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Оптимизация загрузки
|
||||||
|
|
||||||
|
#### Lazy loading
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Загружаем YAML парсер только когда нужен
|
||||||
|
async function loadYamlParser() {
|
||||||
|
if (!window.jsyaml) {
|
||||||
|
await import('../../node_modules/js-yaml/dist/js-yaml.min.js');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Кэширование
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Кэшируем результаты API запросов
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
async function cachedApiCall(endpoint, data) {
|
||||||
|
const key = `${endpoint}:${JSON.stringify(data)}`;
|
||||||
|
if (cache.has(key)) {
|
||||||
|
return cache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCall(endpoint, data);
|
||||||
|
cache.set(key, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Безопасность
|
||||||
|
|
||||||
|
### Принципы безопасности Electron
|
||||||
|
|
||||||
|
#### 1. Context Isolation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true // Изолирует контекст renderer от Node.js
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Node Integration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false // Отключает прямой доступ к require()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Sandbox
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
webPreferences: {
|
||||||
|
sandbox: false // Позволяет preload работать (но только в preload)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. CSP (Content Security Policy)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline';">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Валидация входных данных
|
||||||
|
|
||||||
|
#### Проверка паролей
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function validatePassword(password) {
|
||||||
|
if (!password || password.length < 1) {
|
||||||
|
throw new Error('Пароль не может быть пустым');
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Проверка файлов
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function validateFileContent(content) {
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
throw new Error('Неверный формат файла');
|
||||||
|
}
|
||||||
|
if (content.length > 10 * 1024 * 1024) { // 10MB лимит
|
||||||
|
throw new Error('Файл слишком большой');
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Проверка API ответов
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function safeApiCall(url, options) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API call failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Устранение неполадок
|
||||||
|
|
||||||
|
### Частые проблемы и решения
|
||||||
|
|
||||||
|
#### 1. Приложение не запускается
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка зависимостей
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Очистка и переустановка
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Проверка версии Node.js
|
||||||
|
node --version # Должна быть >= 16
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. DevTools не открываются
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Убедитесь что в main.js есть:
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
|
||||||
|
// Или добавьте горячую клавишу:
|
||||||
|
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||||
|
if (input.control && input.shift && input.key.toLowerCase() === 'i') {
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Файлы не открываются
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Проверьте что backend запущен
|
||||||
|
const testConnection = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8080/api/v1/health');
|
||||||
|
console.log('Backend is running');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backend is not running:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Сборка не работает
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Очистка dist папки
|
||||||
|
rm -rf dist
|
||||||
|
|
||||||
|
# Проверка конфигурации
|
||||||
|
npm run build -- --debug
|
||||||
|
|
||||||
|
# Сборка с подробными логами
|
||||||
|
DEBUG=electron-builder npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. IPC сообщения не работают
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Проверьте что preload скрипт загружается
|
||||||
|
console.log('Preload loaded:', typeof window.api);
|
||||||
|
|
||||||
|
// Проверьте IPC каналы
|
||||||
|
ipcRenderer.invoke('test').then(result => {
|
||||||
|
console.log('IPC test result:', result);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Отладка производительности
|
||||||
|
|
||||||
|
#### Профилирование
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В main.js
|
||||||
|
const { performance } = require('perf_hooks');
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
// ... код ...
|
||||||
|
const endTime = performance.now();
|
||||||
|
console.log(`Operation took ${endTime - startTime} milliseconds`);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Мониторинг памяти
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В main.js
|
||||||
|
setInterval(() => {
|
||||||
|
const usage = process.memoryUsage();
|
||||||
|
console.log('Memory usage:', {
|
||||||
|
rss: Math.round(usage.rss / 1024 / 1024) + ' MB',
|
||||||
|
heapTotal: Math.round(usage.heapTotal / 1024 / 1024) + ' MB',
|
||||||
|
heapUsed: Math.round(usage.heapUsed / 1024 / 1024) + ' MB'
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логирование и мониторинг
|
||||||
|
|
||||||
|
#### Структурированное логирование
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В main.js
|
||||||
|
const log = {
|
||||||
|
info: (message, data) => console.log(`[INFO] ${message}`, data),
|
||||||
|
error: (message, error) => console.error(`[ERROR] ${message}`, error),
|
||||||
|
debug: (message, data) => console.debug(`[DEBUG] ${message}`, data)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Использование
|
||||||
|
log.info('Application started');
|
||||||
|
log.error('File operation failed', error);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Отслеживание ошибок
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Глобальный обработчик ошибок
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('Uncaught Exception:', error);
|
||||||
|
// Можно отправить в сервис мониторинга
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Дополнительные ресурсы
|
||||||
|
|
||||||
|
### Документация
|
||||||
|
|
||||||
|
- [Electron Documentation](https://www.electronjs.org/docs)
|
||||||
|
- [Electron Builder](https://www.electron.build/)
|
||||||
|
- [Context Isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation)
|
||||||
|
- [IPC Communication](https://www.electronjs.org/docs/latest/tutorial/ipc)
|
||||||
|
|
||||||
|
### Лучшие практики
|
||||||
|
|
||||||
|
- [Electron Security](https://www.electronjs.org/docs/latest/tutorial/security)
|
||||||
|
- [Performance Best Practices](https://www.electronjs.org/docs/latest/tutorial/performance)
|
||||||
|
- [Distribution Guide](https://www.electronjs.org/docs/latest/tutorial/distribution)
|
||||||
|
|
||||||
|
### Инструменты разработки
|
||||||
|
|
||||||
|
- [Electron DevTools](https://www.electronjs.org/docs/latest/tutorial/devtools)
|
||||||
|
- [VS Code Electron Extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-electron)
|
||||||
|
- [Electron Fiddle](https://www.electronjs.org/fiddle)
|
||||||
|
|
||||||
|
## 🤝 Вклад в разработку
|
||||||
|
|
||||||
|
### Процесс разработки
|
||||||
|
|
||||||
|
1. Форкните репозиторий
|
||||||
|
2. Создайте ветку для новой функции: `git checkout -b feature/new-feature`
|
||||||
|
3. Внесите изменения с тестами
|
||||||
|
4. Проверьте на всех платформах: `npm run build:win && npm run build:linux && npm run build:mac`
|
||||||
|
5. Создайте Pull Request с описанием изменений
|
||||||
|
|
||||||
|
### Стандарты кода
|
||||||
|
|
||||||
|
- Используйте ESLint для проверки JavaScript
|
||||||
|
- Комментируйте сложную логику
|
||||||
|
- Следуйте принципам безопасности Electron
|
||||||
|
- Тестируйте на всех поддерживаемых платформах
|
||||||
|
|
||||||
|
### Тестирование (another)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Полный цикл тестирования
|
||||||
|
npm run dev # Тест в режиме разработки
|
||||||
|
npm run pack # Тест упакованной версии
|
||||||
|
npm run build # Тест финальной сборки
|
||||||
|
npm run build:win # Тест Windows версии
|
||||||
|
npm run build:linux # Тест Linux версии
|
||||||
|
npm run build:mac # Тест macOS версии
|
||||||
|
```
|
||||||
|
|
||||||
|
Это руководство покрывает все аспекты разработки Electron приложения Knocker Desktop. Используйте его как справочник при работе с проектом.
|
||||||
186
desktop/GATEWAY_EXPLANATION.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# Объяснение работы Gateway и localAddress
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
При использовании VPN (например, WireGuard) весь интернет-трафик направляется через туннель. Однако для порт-простукивания может потребоваться использовать локальный интерфейс для обхода VPN.
|
||||||
|
|
||||||
|
## Решение: localAddress
|
||||||
|
|
||||||
|
### Как работает localAddress
|
||||||
|
|
||||||
|
`localAddress` - это параметр в Node.js Socket API, который позволяет указать локальный IP-адрес для исходящих соединений. Это заставляет операционную систему использовать конкретный сетевой интерфейс вместо маршрута по умолчанию.
|
||||||
|
|
||||||
|
### TCP соединения
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const socket = new net.Socket();
|
||||||
|
|
||||||
|
// Обычное соединение (через маршрут по умолчанию, может идти через VPN)
|
||||||
|
socket.connect(80, 'example.com');
|
||||||
|
|
||||||
|
// Соединение через конкретный локальный IP (обходит VPN)
|
||||||
|
socket.connect({
|
||||||
|
port: 80,
|
||||||
|
host: 'example.com',
|
||||||
|
localAddress: '192.168.89.1' // Ваш локальный шлюз
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно**: TCP сокеты НЕ поддерживают `socket.bind()`. Используйте `localAddress` в `socket.connect()`.
|
||||||
|
|
||||||
|
### UDP пакеты
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
|
||||||
|
// Привязка к конкретному локальному IP (работает для UDP)
|
||||||
|
socket.bind(0, '192.168.89.1');
|
||||||
|
|
||||||
|
// Отправка пакета через этот интерфейс
|
||||||
|
socket.send(message, 0, message.length, 53, '8.8.8.8');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно**: UDP сокеты поддерживают `socket.bind()` для привязки к локальному IP.
|
||||||
|
|
||||||
|
## Ваш случай с WireGuard
|
||||||
|
|
||||||
|
### Текущая ситуация:
|
||||||
|
- WireGuard активен
|
||||||
|
- Весь трафик идет через туннель
|
||||||
|
- Нужно простучать порт через локальный шлюз `192.168.89.1`
|
||||||
|
|
||||||
|
### Решение:
|
||||||
|
```javascript
|
||||||
|
// В настройках приложения указать:
|
||||||
|
{
|
||||||
|
"apiBase": "internal",
|
||||||
|
"gateway": "192.168.89.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Или в строке цели:
|
||||||
|
"tcp:example.com:22:192.168.89.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что происходит:
|
||||||
|
1. Приложение получает gateway `192.168.89.1`
|
||||||
|
2. Создается сокет с `localAddress: '192.168.89.1'`
|
||||||
|
3. Операционная система направляет трафик через интерфейс с IP `192.168.89.1`
|
||||||
|
4. Трафик обходит WireGuard туннель
|
||||||
|
|
||||||
|
## Технические детали
|
||||||
|
|
||||||
|
### TCP (socket.connect с localAddress)
|
||||||
|
```javascript
|
||||||
|
socket.connect({
|
||||||
|
port: 22,
|
||||||
|
host: '192.168.1.100',
|
||||||
|
localAddress: '192.168.89.1' // Принудительно использует этот локальный IP
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### UDP (socket.bind с localAddress)
|
||||||
|
```javascript
|
||||||
|
socket.bind(0, '192.168.89.1'); // Привязка к локальному IP
|
||||||
|
socket.send(message, port, host); // Отправка через этот интерфейс
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка работы
|
||||||
|
|
||||||
|
### Логи в консоли
|
||||||
|
```
|
||||||
|
Using localAddress 192.168.89.1 to bypass VPN/tunnel
|
||||||
|
TCP connection to 192.168.1.100:22 via 192.168.89.1 successful
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сетевая диагностика
|
||||||
|
```bash
|
||||||
|
# Проверить маршруты
|
||||||
|
ip route show
|
||||||
|
|
||||||
|
# Проверить интерфейсы
|
||||||
|
ip addr show
|
||||||
|
|
||||||
|
# Мониторинг трафика
|
||||||
|
tcpdump -i any host 192.168.1.100
|
||||||
|
```
|
||||||
|
|
||||||
|
## Возможные проблемы
|
||||||
|
|
||||||
|
### 1. "EADDRNOTAVAIL" ошибка
|
||||||
|
**Причина**: IP-адрес не существует на локальной машине
|
||||||
|
**Решение**: Указать корректный IP локального интерфейса
|
||||||
|
|
||||||
|
### 2. "ENETUNREACH" ошибка
|
||||||
|
**Причина**: Нет маршрута к цели через указанный интерфейс
|
||||||
|
**Решение**: Проверить сетевую конфигурацию
|
||||||
|
|
||||||
|
### 3. Трафик все еще идет через VPN
|
||||||
|
**Причина**: Неправильно указан localAddress
|
||||||
|
**Решение**:
|
||||||
|
```bash
|
||||||
|
# Найти локальный IP шлюза
|
||||||
|
ip route | grep default
|
||||||
|
# Использовать этот IP как gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры конфигурации
|
||||||
|
|
||||||
|
### Конфигурация для обхода WireGuard
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiBase": "internal",
|
||||||
|
"gateway": "192.168.89.1",
|
||||||
|
"inlineTargets": "tcp:external-server.com:22",
|
||||||
|
"delay": "1s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Смешанное использование
|
||||||
|
```
|
||||||
|
tcp:127.0.0.1:22;tcp:external-server.com:22:192.168.89.1;udp:local-dns.com:53
|
||||||
|
```
|
||||||
|
- Первая цель: через VPN (системный маршрут)
|
||||||
|
- Вторая цель: через локальный шлюз (обход VPN)
|
||||||
|
- Третья цель: через VPN (системный маршрут)
|
||||||
|
|
||||||
|
## Отладка
|
||||||
|
|
||||||
|
### Включить подробные логи
|
||||||
|
```javascript
|
||||||
|
// В настройках установить verbose: true
|
||||||
|
{
|
||||||
|
"verbose": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверить в консоли main процесса
|
||||||
|
```
|
||||||
|
Knocking TCP external-server.com:22 via 192.168.89.1
|
||||||
|
Using localAddress 192.168.89.1 to bypass VPN/tunnel
|
||||||
|
TCP connection to external-server.com:22 via 192.168.89.1 successful
|
||||||
|
```
|
||||||
|
|
||||||
|
### Мониторинг сети
|
||||||
|
```bash
|
||||||
|
# Просмотр активных соединений
|
||||||
|
netstat -an | grep 192.168.89.1
|
||||||
|
|
||||||
|
# Мониторинг трафика
|
||||||
|
sudo tcpdump -i any -n host external-server.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
### Ограничения
|
||||||
|
- `localAddress` работает только с IP-адресами, существующими на локальной машине
|
||||||
|
- Необходимы соответствующие права для привязки к сетевым интерфейсам
|
||||||
|
- Подчиняется правилам файрвола операционной системы
|
||||||
|
|
||||||
|
### Рекомендации
|
||||||
|
- Использовать только доверенные IP-адреса
|
||||||
|
- Проверять сетевую конфигурацию перед использованием
|
||||||
|
- Логировать все попытки обхода VPN для аудита
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Важно**: `localAddress` - это мощный инструмент для управления сетевым трафиком, но он должен использоваться осторожно, так как может обходить сетевые политики безопасности.
|
||||||
411
desktop/LOCAL_KNOCKING.md
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
# Локальное простукивание портов (Local Port Knocking)
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Функционал локального простукивания позволяет выполнять knock операции напрямую через Node.js API без использования внешнего HTTP API сервера. Это обеспечивает независимость от внешних сервисов и возможность работы в автономном режиме.
|
||||||
|
|
||||||
|
## Условия активации
|
||||||
|
|
||||||
|
Локальное простукивание активируется автоматически когда:
|
||||||
|
|
||||||
|
1. **API URL пуст** - поле `apiBase` не заполнено или содержит пустую строку
|
||||||
|
2. **API URL = "internal"** - значение `apiBase` установлено в `"internal"`
|
||||||
|
3. **API URL не задан** - значение `apiBase` равно `null` или `undefined`
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
### Файлы реализации
|
||||||
|
|
||||||
|
#### 1. `src/main/main.js` - Основная логика
|
||||||
|
|
||||||
|
**Строки 210-367**: Реализация локального простукивания
|
||||||
|
|
||||||
|
**Ключевые функции:**
|
||||||
|
|
||||||
|
- `parseTarget(targetStr)` - парсинг строки цели в объект
|
||||||
|
- `parseDelay(delayStr)` - конвертация задержки в миллисекунды
|
||||||
|
- `knockTcp(host, port, timeout)` - TCP простукивание
|
||||||
|
- `knockUdp(host, port, timeout)` - UDP простукивание
|
||||||
|
- `performLocalKnock(targets, delay, verbose)` - основная функция простукивания
|
||||||
|
- `ipcMain.handle('knock:local', ...)` - IPC обработчик
|
||||||
|
|
||||||
|
**Поддерживаемые протоколы:**
|
||||||
|
|
||||||
|
- **TCP** - создает соединение и немедленно закрывает
|
||||||
|
- **UDP** - отправляет пакет данных (fire-and-forget)
|
||||||
|
|
||||||
|
**Формат целей:**
|
||||||
|
|
||||||
|
``` text
|
||||||
|
protocol:host:port[:gateway]
|
||||||
|
```
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `tcp:127.0.0.1:22`
|
||||||
|
- `udp:192.168.1.1:53`
|
||||||
|
- `tcp:example.com:80:gateway.com`
|
||||||
|
|
||||||
|
**Поддержка Gateway:**
|
||||||
|
|
||||||
|
Gateway можно указать двумя способами:
|
||||||
|
|
||||||
|
1. **В строке цели**: `tcp:host:port:gateway_ip`
|
||||||
|
2. **Глобально через поле Gateway**: используется для всех целей, если не указан в самой цели
|
||||||
|
|
||||||
|
**Приоритет gateway:**
|
||||||
|
|
||||||
|
- Gateway из строки цели имеет приоритет над глобальным
|
||||||
|
- Если gateway не указан, используется системный маршрут по умолчанию
|
||||||
|
|
||||||
|
**Обход VPN/туннелей:**
|
||||||
|
|
||||||
|
Gateway использует `localAddress` для принудительного направления трафика через указанный локальный IP-адрес. Это позволяет:
|
||||||
|
- Обходить VPN соединения (WireGuard, OpenVPN и др.)
|
||||||
|
- Использовать конкретный сетевой интерфейс
|
||||||
|
- Направлять трафик через локальный шлюз
|
||||||
|
|
||||||
|
**Пример обхода WireGuard:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gateway": "192.168.89.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Трафик будет направлен через интерфейс с IP `192.168.89.1`, минуя WireGuard туннель.
|
||||||
|
|
||||||
|
## Хелпер для gateway (Rust приоритетно, Go как fallback)
|
||||||
|
|
||||||
|
Когда задан `gateway` (IP или имя интерфейса), десктоп-приложение запускает встроенный бинарь из `desktop/bin/`:
|
||||||
|
|
||||||
|
- `knock-local-rust` — приоритетный Rust-хелпер (если присутствует)
|
||||||
|
- `knock-local` — Go-хелпер как запасной вариант
|
||||||
|
|
||||||
|
Оба на Linux используют `SO_BINDTODEVICE` для привязки к интерфейсу и надежного обхода VPN/туннелей (WireGuard и пр.).
|
||||||
|
|
||||||
|
Сборка при разработке:
|
||||||
|
|
||||||
|
- `npm run rust:build` — соберёт Rust-хелпер
|
||||||
|
- `npm run go:build` — соберёт Go-хелпер
|
||||||
|
|
||||||
|
В прод-сборках оба бинаря автоматически включаются в образ приложения.
|
||||||
|
|
||||||
|
Важно для TCP: привязка интерфейса устанавливается до `connect()`. Это гарантирует, что исходящее соединение пойдёт через нужный интерфейс, а не в туннель.
|
||||||
|
|
||||||
|
**Формат задержки:**
|
||||||
|
|
||||||
|
- `1s` - 1 секунда
|
||||||
|
- `500ms` - 500 миллисекунд (не поддерживается, используйте `0.5s`)
|
||||||
|
- `2m` - 2 минуты
|
||||||
|
- `1h` - 1 час
|
||||||
|
|
||||||
|
#### 2. `src/preload/preload.js` - IPC мост
|
||||||
|
|
||||||
|
**Строка 13**: Добавлен метод `localKnock`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
localKnock: async (payload) => ipcRenderer.invoke('knock:local', payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `src/renderer/renderer.js` - UI логика
|
||||||
|
|
||||||
|
**Строки 317-376**: Логика выбора между локальным и API простукиванием
|
||||||
|
|
||||||
|
**Ключевые изменения:**
|
||||||
|
|
||||||
|
- Проверка условия `useLocalKnock = !apiBase || apiBase.trim() === '' || apiBase === 'internal'`
|
||||||
|
- Извлечение targets из всех режимов (inline, form, yaml)
|
||||||
|
- Вызов `window.api.localKnock()` вместо HTTP запросов
|
||||||
|
|
||||||
|
## Режимы работы
|
||||||
|
|
||||||
|
### 1. Inline режим
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Извлекает targets из поля #targets
|
||||||
|
targets = qsi("#targets").value.split(';').filter(t => t.trim());
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Form режим
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Сериализует формы в строку targets
|
||||||
|
targets = [serializeFormTargetsToInline()];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. YAML режим
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Парсит YAML и извлекает targets
|
||||||
|
const config = yaml.load(yamlContent);
|
||||||
|
targets = config.targets.map(t => {
|
||||||
|
const protocol = t.protocol || 'tcp';
|
||||||
|
const host = t.host || '127.0.0.1';
|
||||||
|
const ports = t.ports || [t.port] || [22];
|
||||||
|
return ports.map(port => `${protocol}:${host}:${port}`);
|
||||||
|
}).flat();
|
||||||
|
```
|
||||||
|
|
||||||
|
## API локального простукивания
|
||||||
|
|
||||||
|
### Входные параметры
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
targets: string[], // Массив целей в формате "protocol:host:port[:gateway]"
|
||||||
|
delay: string, // Задержка между целями (например "1s")
|
||||||
|
verbose: boolean, // Подробный вывод в консоль
|
||||||
|
gateway: string // Глобальный gateway для всех целей (опционально)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Выходные данные
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
success: boolean, // Успешность операции
|
||||||
|
results: [ // Детальные результаты по каждой цели
|
||||||
|
{
|
||||||
|
target: string, // Исходная строка цели
|
||||||
|
success: boolean, // Успешность простукивания
|
||||||
|
message: string // Сообщение о результате
|
||||||
|
}
|
||||||
|
],
|
||||||
|
summary: { // Общая статистика
|
||||||
|
total: number, // Общее количество целей
|
||||||
|
successful: number, // Количество успешных
|
||||||
|
failed: number // Количество неудачных
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Настройка для локального режима
|
||||||
|
|
||||||
|
#### Вариант 1: Пустой API URL
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiBase": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Вариант 2: Специальное значение
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiBase": "internal"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример конфигурации
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiBase": "internal",
|
||||||
|
"gateway": "192.168.1.1",
|
||||||
|
"inlineTargets": "tcp:127.0.0.1:22;tcp:192.168.1.100:80",
|
||||||
|
"delay": "2s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример YAML конфигурации
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
targets:
|
||||||
|
- protocol: tcp
|
||||||
|
host: 127.0.0.1
|
||||||
|
ports: [22, 80]
|
||||||
|
- protocol: udp
|
||||||
|
host: 192.168.1.1
|
||||||
|
ports: [53]
|
||||||
|
delay: 1s
|
||||||
|
```
|
||||||
|
|
||||||
|
## Логирование и отладка
|
||||||
|
|
||||||
|
### Консольный вывод
|
||||||
|
|
||||||
|
При `verbose: true` в консоли main процесса появляются сообщения:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
Knocking TCP 127.0.0.1:22
|
||||||
|
Knocking UDP 192.168.1.1:53 via 192.168.1.1
|
||||||
|
Knocking TCP example.com:80 via 10.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Результаты в DevTools
|
||||||
|
|
||||||
|
Детальные результаты логируются в консоль renderer процесса:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
console.log('Local knock results:', result.results);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Статус в UI
|
||||||
|
|
||||||
|
В интерфейсе отображается краткий статус:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
"Локальное простукивание завершено: 2/3 успешно"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
### Поддерживаемые протоколы
|
||||||
|
|
||||||
|
- ✅ **TCP** - полная поддержка
|
||||||
|
- ✅ **UDP** - отправка пакетов
|
||||||
|
- ❌ **ICMP** - не поддерживается
|
||||||
|
- ❌ **Другие протоколы** - не поддерживаются
|
||||||
|
|
||||||
|
### Таймауты
|
||||||
|
|
||||||
|
- **TCP**: 5 секунд по умолчанию
|
||||||
|
- **UDP**: 5 секунд по умолчанию
|
||||||
|
- Настраивается в коде функций `knockTcp` и `knockUdp`
|
||||||
|
|
||||||
|
### Сетевая безопасность
|
||||||
|
|
||||||
|
- Локальное простукивание использует системные сокеты
|
||||||
|
- Подчиняется правилам файрвола операционной системы
|
||||||
|
- Не требует дополнительных разрешений в Electron
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
### Операционные системы
|
||||||
|
|
||||||
|
- ✅ **Windows** - полная поддержка
|
||||||
|
- ✅ **macOS** - полная поддержка
|
||||||
|
- ✅ **Linux** - полная поддержка
|
||||||
|
|
||||||
|
### Electron версии
|
||||||
|
|
||||||
|
- ✅ **v28+** - протестировано
|
||||||
|
- ⚠️ **v27 и ниже** - может потребовать адаптации
|
||||||
|
|
||||||
|
## Переключение между режимами
|
||||||
|
|
||||||
|
### API → Локальный
|
||||||
|
|
||||||
|
1. Открыть настройки (Ctrl/Cmd+,)
|
||||||
|
2. Установить `apiBase` в `"internal"`
|
||||||
|
3. Сохранить настройки
|
||||||
|
4. Перезапустить приложение
|
||||||
|
|
||||||
|
### Локальный → API
|
||||||
|
|
||||||
|
1. Открыть настройки
|
||||||
|
2. Установить корректный `apiBase` URL
|
||||||
|
3. Сохранить настройки
|
||||||
|
4. Перезапустить приложение
|
||||||
|
|
||||||
|
## Устранение неполадок
|
||||||
|
|
||||||
|
### Проблема: "No targets provided"
|
||||||
|
|
||||||
|
**Причина**: Не удалось извлечь цели из конфигурации
|
||||||
|
**Решение**: Проверить корректность заполнения полей targets
|
||||||
|
|
||||||
|
### Проблема: "Unsupported protocol"
|
||||||
|
|
||||||
|
**Причина**: Использован неподдерживаемый протокол
|
||||||
|
**Решение**: Использовать только `tcp` или `udp`
|
||||||
|
|
||||||
|
### Проблема: "Connection timeout"
|
||||||
|
|
||||||
|
**Причина**: Цель недоступна или заблокирована файрволом
|
||||||
|
**Решение**: Проверить доступность цели и настройки файрвола
|
||||||
|
|
||||||
|
### Проблема: "Invalid target format"
|
||||||
|
|
||||||
|
**Причина**: Неверный формат строки цели
|
||||||
|
**Решение**: Использовать формат `protocol:host:port`
|
||||||
|
|
||||||
|
### Проблема: "Uncaught Exception"
|
||||||
|
|
||||||
|
**Причина**: Необработанные ошибки в асинхронных операциях
|
||||||
|
**Решение**: ✅ **ИСПРАВЛЕНО** - Добавлены глобальные обработчики ошибок и защита от двойного resolve
|
||||||
|
|
||||||
|
**Исправления в версии 1.1:**
|
||||||
|
|
||||||
|
- Добавлен флаг `resolved` в TCP/UDP функциях для предотвращения двойного вызова resolve
|
||||||
|
- Глобальные обработчики `uncaughtException` и `unhandledRejection` в main процессе
|
||||||
|
- Глобальные обработчики ошибок в renderer процессе
|
||||||
|
- Улучшенная валидация входных данных в IPC обработчике
|
||||||
|
- Try-catch блоки вокруг всех критических операций
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
### Ограничения доступа
|
||||||
|
|
||||||
|
- Локальное простукивание выполняется с правами пользователя приложения
|
||||||
|
- Не требует root/administrator прав
|
||||||
|
- Подчиняется системным ограничениям сетевого доступа
|
||||||
|
|
||||||
|
### Логирование
|
||||||
|
|
||||||
|
- Результаты простукивания логируются в консоль
|
||||||
|
- Не сохраняются в файлы по умолчанию
|
||||||
|
- Можно отключить через параметр `verbose: false`
|
||||||
|
|
||||||
|
## Разработка и расширение
|
||||||
|
|
||||||
|
### Добавление новых протоколов
|
||||||
|
|
||||||
|
1. Создать функцию `knockProtocol()` в `src/main/main.js`
|
||||||
|
2. Добавить обработку в `performLocalKnock()`
|
||||||
|
3. Обновить документацию
|
||||||
|
|
||||||
|
### Настройка таймаутов
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В src/main/main.js
|
||||||
|
function knockTcp(host, port, timeout = 10000) { // 10 секунд
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Добавление дополнительных опций
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Расширить payload в IPC
|
||||||
|
{
|
||||||
|
targets: string[],
|
||||||
|
delay: string,
|
||||||
|
verbose: boolean,
|
||||||
|
timeout: number, // новый параметр
|
||||||
|
retries: number // новый параметр
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример обхода WireGuard
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
WireGuard активен, весь трафик идет через туннель, но нужно простучать порт через локальный шлюз.
|
||||||
|
|
||||||
|
### Решение
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiBase": "internal",
|
||||||
|
"gateway": "192.168.89.1",
|
||||||
|
"inlineTargets": "tcp:external-server.com:22",
|
||||||
|
"delay": "1s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
```
|
||||||
|
Using localAddress 192.168.89.1 to bypass VPN/tunnel
|
||||||
|
Knocking TCP external-server.com:22 via 192.168.89.1
|
||||||
|
TCP connection to external-server.com:22 via 192.168.89.1 successful
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Версия документации**: 1.2
|
||||||
|
**Дата создания**: 2024
|
||||||
|
**Дата обновления**: 2024 (поддержка обхода VPN)
|
||||||
|
**Совместимость**: Electron Desktop App v1.0+
|
||||||
356
desktop/README.md
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
# Knocker Desktop - Electron приложение
|
||||||
|
|
||||||
|
Независимое десктопное приложение для Port Knocker с полным функционалом веб-версии.
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### Установка и запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd desktop
|
||||||
|
npm install
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сборка для продакшена
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка для текущей платформы
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Сборка для конкретных платформ
|
||||||
|
npm run build:win # Windows
|
||||||
|
npm run build:linux # Linux
|
||||||
|
npm run build:mac # macOS
|
||||||
|
|
||||||
|
# Упаковка без установщика (для тестирования)
|
||||||
|
npm run pack
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Архитектура приложения
|
||||||
|
|
||||||
|
### Структура проекта
|
||||||
|
|
||||||
|
``` text
|
||||||
|
desktop/
|
||||||
|
├── src/
|
||||||
|
│ ├── main/ # Основной процесс Electron
|
||||||
|
│ │ ├── main.js # Точка входа, управление окнами
|
||||||
|
│ │ └── main.ts # TypeScript версия (опционально)
|
||||||
|
│ ├── preload/ # Preload скрипты (мост между main и renderer)
|
||||||
|
│ │ ├── preload.js # Безопасный API для renderer процесса
|
||||||
|
│ │ └── preload.ts # TypeScript версия
|
||||||
|
│ └── renderer/ # Процесс рендеринга (UI)
|
||||||
|
│ ├── index.html # HTML разметка
|
||||||
|
│ ├── styles.css # Стили
|
||||||
|
│ ├── renderer.js # Логика UI (ванильный JS)
|
||||||
|
│ └── renderer.ts # TypeScript версия
|
||||||
|
├── assets/ # Иконки для сборки
|
||||||
|
├── dist/ # Собранные приложения
|
||||||
|
├── package.json # Конфигурация и зависимости
|
||||||
|
└── README.md # Документация
|
||||||
|
```
|
||||||
|
|
||||||
|
### Как работает Electron
|
||||||
|
|
||||||
|
Electron состоит из двух основных процессов:
|
||||||
|
|
||||||
|
1. **Main Process (Основной процесс)** - `src/main/main.js`
|
||||||
|
- Управляет жизненным циклом приложения
|
||||||
|
- Создает и управляет окнами браузера
|
||||||
|
- Обеспечивает безопасный доступ к Node.js API
|
||||||
|
- Обрабатывает системные события (закрытие, фокус и т.д.)
|
||||||
|
|
||||||
|
2. **Renderer Process (Процесс рендеринга)** - `src/renderer/`
|
||||||
|
- Отображает пользовательский интерфейс
|
||||||
|
- Работает как обычная веб-страница (HTML/CSS/JS)
|
||||||
|
- Изолирован от Node.js API по соображениям безопасности
|
||||||
|
- Взаимодействует с main процессом через IPC (Inter-Process Communication)
|
||||||
|
|
||||||
|
3. **Preload Script (Preload скрипт)** - `src/preload/preload.js`
|
||||||
|
- Выполняется в renderer процессе, но имеет доступ к Node.js API
|
||||||
|
- Создает безопасный мост между main и renderer процессами
|
||||||
|
- Экспонирует только необходимые API через `contextBridge`
|
||||||
|
|
||||||
|
### Безопасность
|
||||||
|
|
||||||
|
Приложение использует современные принципы безопасности Electron:
|
||||||
|
|
||||||
|
- `contextIsolation: true` - изолирует контекст renderer от Node.js
|
||||||
|
- `nodeIntegration: false` - отключает прямой доступ к Node.js в renderer
|
||||||
|
- `sandbox: false` - позволяет preload скрипту работать (но только в preload)
|
||||||
|
|
||||||
|
## 🔧 Разработка
|
||||||
|
|
||||||
|
### Локальная разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Откроет приложение с включенными DevTools для отладки.
|
||||||
|
|
||||||
|
### Структура кода
|
||||||
|
|
||||||
|
#### Main Process (`src/main/main.js`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
|
||||||
|
|
||||||
|
// Создание главного окна
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1100,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/preload.js'),
|
||||||
|
contextIsolation: true, // Безопасность
|
||||||
|
nodeIntegration: false, // Безопасность
|
||||||
|
sandbox: false // Для preload
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC обработчики для файловых операций
|
||||||
|
ipcMain.handle('file:open', async () => {
|
||||||
|
const res = await dialog.showOpenDialog({...});
|
||||||
|
// Возвращает файл в renderer процесс
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Preload Script (`src/preload/preload.js`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
// Безопасный API для renderer процесса
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
openFile: () => ipcRenderer.invoke('file:open'),
|
||||||
|
saveAs: (payload) => ipcRenderer.invoke('file:saveAs', payload),
|
||||||
|
// ... другие методы
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Renderer Process (`src/renderer/renderer.js`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Используем безопасный API из preload
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('openFile').addEventListener('click', async () => {
|
||||||
|
const result = await window.api.openFile();
|
||||||
|
// Обрабатываем результат
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Функциональность
|
||||||
|
|
||||||
|
#### Режимы работы
|
||||||
|
|
||||||
|
1. **Inline режим** - простые поля для ввода targets, delay, verbose
|
||||||
|
2. **YAML режим** - редактирование YAML конфигурации с поддержкой файлов
|
||||||
|
3. **Form режим** - табличная форма для добавления/удаления целей
|
||||||
|
|
||||||
|
#### Файловые операции
|
||||||
|
|
||||||
|
- Открытие файлов через системный диалог
|
||||||
|
- Сохранение файлов с предложением имени
|
||||||
|
- Автоматическое извлечение `path` из YAML
|
||||||
|
- Синхронизация между YAML и serverFilePath полем
|
||||||
|
|
||||||
|
#### HTTP API
|
||||||
|
|
||||||
|
- Вызовы к `http://localhost:8080/api/v1/knock-actions/*`
|
||||||
|
- Basic Authentication с пользователем `knocker`
|
||||||
|
- Выполнение knock операций
|
||||||
|
- Шифрование/дешифрование конфигураций
|
||||||
|
|
||||||
|
### Отладка
|
||||||
|
|
||||||
|
#### DevTools
|
||||||
|
|
||||||
|
DevTools автоматически открываются при запуске в режиме разработки (`npm run dev`).
|
||||||
|
|
||||||
|
#### Консольные сообщения
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В renderer процессе
|
||||||
|
console.log('Debug info:', data);
|
||||||
|
|
||||||
|
// В main процессе
|
||||||
|
console.log('Main process log:', data);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### IPC отладка
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В preload можно добавить логирование
|
||||||
|
ipcRenderer.invoke('file:open').then(result => {
|
||||||
|
console.log('IPC result:', result);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Сборка и распространение
|
||||||
|
|
||||||
|
### Electron Builder конфигурация
|
||||||
|
|
||||||
|
В `package.json` настроена конфигурация `electron-builder`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"appId": "com.knocker.desktop",
|
||||||
|
"productName": "Knocker Desktop",
|
||||||
|
"files": ["src/**/*", "node_modules/**/*"],
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"icon": "assets/icon.ico"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage",
|
||||||
|
"icon": "assets/icon.png"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg",
|
||||||
|
"icon": "assets/icon.icns"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Типы сборки
|
||||||
|
|
||||||
|
- **NSIS** (Windows) - установщик с мастером установки
|
||||||
|
- **AppImage** (Linux) - портативное приложение
|
||||||
|
- **DMG** (macOS) - образ диска для установки
|
||||||
|
|
||||||
|
### Команды сборки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # Сборка для текущей платформы
|
||||||
|
npm run build:win # Сборка для Windows
|
||||||
|
npm run build:linux # Сборка для Linux
|
||||||
|
npm run build:mac # Сборка для macOS
|
||||||
|
npm run pack # Упаковка без установщика
|
||||||
|
npm run dist # Сборка без публикации
|
||||||
|
```
|
||||||
|
|
||||||
|
### Иконки
|
||||||
|
|
||||||
|
Поместите иконки в папку `assets/`:
|
||||||
|
|
||||||
|
- `icon.ico` - для Windows (256x256)
|
||||||
|
- `icon.png` - для Linux (512x512)
|
||||||
|
- `icon.icns` - для macOS (512x512)
|
||||||
|
|
||||||
|
## 🔄 Интеграция с веб-версией
|
||||||
|
|
||||||
|
### Общие компоненты
|
||||||
|
|
||||||
|
- HTTP API остается тем же (`/api/v1/knock-actions/*`)
|
||||||
|
- YAML формат конфигурации идентичен
|
||||||
|
- Логика шифрования/дешифрования совместима
|
||||||
|
|
||||||
|
### Различия
|
||||||
|
|
||||||
|
- **Файловые операции**: Electron dialog вместо браузерных File API
|
||||||
|
- **UI библиотеки**: ванильный JS вместо Angular/PrimeNG
|
||||||
|
- **Автосохранение**: localStorage в веб-версии, файловая система в desktop
|
||||||
|
- **FSA API**: не нужен в desktop версии
|
||||||
|
|
||||||
|
### Миграция данных
|
||||||
|
|
||||||
|
Пользователи могут переносить конфигурации между версиями через:
|
||||||
|
|
||||||
|
- Экспорт/импорт YAML файлов
|
||||||
|
- Копирование содержимого между интерфейсами
|
||||||
|
- Использование одинаковых server paths
|
||||||
|
|
||||||
|
## 🐛 Устранение неполадок
|
||||||
|
|
||||||
|
### Частые проблемы
|
||||||
|
|
||||||
|
#### Приложение не запускается
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте зависимости
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Очистите node_modules
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DevTools не открываются
|
||||||
|
|
||||||
|
Убедитесь, что в `src/main/main.js` есть строка:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Файлы не открываются
|
||||||
|
|
||||||
|
Проверьте, что backend сервер запущен на `http://localhost:8080`
|
||||||
|
|
||||||
|
#### Сборка не работает
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Очистите dist папку
|
||||||
|
rm -rf dist
|
||||||
|
|
||||||
|
# Пересоберите
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логи отладки
|
||||||
|
|
||||||
|
#### Main процесс
|
||||||
|
|
||||||
|
Логи main процесса видны в терминале, где запущено приложение.
|
||||||
|
|
||||||
|
#### Renderer процесс
|
||||||
|
|
||||||
|
Логи renderer процесса видны в DevTools Console.
|
||||||
|
|
||||||
|
#### IPC сообщения
|
||||||
|
|
||||||
|
Можно добавить логирование в preload для отладки IPC:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const originalInvoke = ipcRenderer.invoke;
|
||||||
|
ipcRenderer.invoke = function(channel, ...args) {
|
||||||
|
console.log(`IPC: ${channel}`, args);
|
||||||
|
return originalInvoke.call(this, channel, ...args);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Дополнительные ресурсы
|
||||||
|
|
||||||
|
- [Electron Documentation](https://www.electronjs.org/docs)
|
||||||
|
- [Electron Builder](https://www.electron.build/)
|
||||||
|
- [Context Isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation)
|
||||||
|
- [IPC Communication](https://www.electronjs.org/docs/latest/tutorial/ipc)
|
||||||
|
|
||||||
|
## 🤝 Вклад в разработку
|
||||||
|
|
||||||
|
1. Форкните репозиторий
|
||||||
|
2. Создайте ветку для новой функции
|
||||||
|
3. Внесите изменения
|
||||||
|
4. Протестируйте на всех платформах
|
||||||
|
5. Создайте Pull Request
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Тест на текущей платформе
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Сборка для тестирования
|
||||||
|
npm run pack
|
||||||
|
|
||||||
|
# Проверка на других платформах
|
||||||
|
npm run build:win
|
||||||
|
npm run build:linux
|
||||||
|
npm run build:mac
|
||||||
|
```
|
||||||
913
desktop/USAGE_GUIDE.md
Normal file
@@ -0,0 +1,913 @@
|
|||||||
|
# Руководство по использованию Desktop приложения
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Desktop версия knocker-приложения предоставляет полный функционал порт-простукивания (port knocking) в виде автономного Electron приложения. Поддерживает как работу через внешний API сервер, так и локальное простукивание через Node.js.
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
1. [Установка и запуск](#установка-и-запуск)
|
||||||
|
2. [Режимы работы](#режимы-работы)
|
||||||
|
3. [Конфигурация](#конфигурация)
|
||||||
|
4. [API контракты](#api-контракты)
|
||||||
|
5. [Локальное простукивание](#локальное-простукивание)
|
||||||
|
6. [Интерфейс пользователя](#интерфейс-пользователя)
|
||||||
|
7. [Примеры использования](#примеры-использования)
|
||||||
|
8. [Устранение неполадок](#устранение-неполадок)
|
||||||
|
9. [Разработка](#разработка)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
### Предварительные требования
|
||||||
|
|
||||||
|
- **Node.js** v18+
|
||||||
|
- **npm** v8+
|
||||||
|
- **Операционная система**: Windows, macOS, Linux
|
||||||
|
|
||||||
|
### Установка зависимостей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd desktop
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Режимы запуска
|
||||||
|
|
||||||
|
#### Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Сборка для продакшена
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Создание исполняемых файлов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dist
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Упаковка для конкретной платформы
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
npm run dist:win
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
npm run dist:mac
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
npm run dist:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Базовый URL API (опционально)
|
||||||
|
export KNOCKER_DESKTOP_API_BASE="http://localhost:8080/api/v1"
|
||||||
|
|
||||||
|
# Запуск в режиме разработки
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Режимы работы
|
||||||
|
|
||||||
|
### 1. API режим (по умолчанию)
|
||||||
|
|
||||||
|
Приложение подключается к внешнему HTTP API серверу для выполнения операций простукивания.
|
||||||
|
|
||||||
|
**Активация:**
|
||||||
|
|
||||||
|
- Установить корректный `apiBase` URL в настройках
|
||||||
|
- Например: `http://localhost:8080/api/v1`
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
|
||||||
|
- ✅ HTTP API простукивание
|
||||||
|
- ✅ Шифрование/расшифровка YAML
|
||||||
|
- ✅ Полная функциональность backend сервера
|
||||||
|
|
||||||
|
### 2. Локальный режим
|
||||||
|
|
||||||
|
Приложение выполняет простукивание напрямую через Node.js сокеты без внешнего API.
|
||||||
|
|
||||||
|
**Активация:**
|
||||||
|
|
||||||
|
- Установить `apiBase` в `""` (пустая строка)
|
||||||
|
- Или установить `apiBase` в `"internal"`
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
|
||||||
|
- ✅ TCP простукивание
|
||||||
|
- ✅ UDP простукивание
|
||||||
|
- ❌ Шифрование/расшифровка (недоступно)
|
||||||
|
- ✅ Автономная работа
|
||||||
|
|
||||||
|
#### Локальное простукивание с gateway (Rust/Go helper)
|
||||||
|
- Если указано `gateway` (IP или имя интерфейса), Electron автоматически запускает встроенный helper из папки `bin/`:
|
||||||
|
- `knock-local-rust` (Rust) — используется приоритетно, если присутствует
|
||||||
|
- `knock-local` (Go) — используется как fallback, если Rust-бинарь отсутствует
|
||||||
|
- Оба helper-а на Linux используют `SO_BINDTODEVICE` для надежной привязки к интерфейсу и обхода VPN/WireGuard.
|
||||||
|
- Если `gateway` не указан — используется встроенная Node-реализация без привязки к интерфейсу.
|
||||||
|
|
||||||
|
Требования при разработке:
|
||||||
|
- Для Rust-хелпера ничего дополнительно не требуется (собирается скриптом `npm run rust:build`).
|
||||||
|
- Для Go-хелпера должен быть установлен Go toolchain (скрипт `npm run go:build`).
|
||||||
|
В релизных сборках оба бинаря включаются автоматически.
|
||||||
|
|
||||||
|
Важно (TCP): привязка интерфейса (`SO_BINDTODEVICE`) устанавливается до `connect()`. Это гарантирует, что исходящее TCP-соединение пойдёт через указанный интерфейс, а не в туннель.
|
||||||
|
|
||||||
|
### 3. Переключение между режимами
|
||||||
|
|
||||||
|
**API → Локальный:**
|
||||||
|
|
||||||
|
1. Открыть настройки (Ctrl/Cmd + ,)
|
||||||
|
2. Установить `apiBase: ""` или `apiBase: "internal"`
|
||||||
|
3. Сохранить настройки
|
||||||
|
4. Перезапустить приложение
|
||||||
|
|
||||||
|
**Локальный → API:**
|
||||||
|
|
||||||
|
1. Открыть настройки
|
||||||
|
2. Установить корректный `apiBase` URL
|
||||||
|
3. Сохранить настройки
|
||||||
|
4. Перезапустить приложение
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Файл конфигурации
|
||||||
|
|
||||||
|
Конфигурация сохраняется в: `~/.config/[app-name]/config.json`
|
||||||
|
|
||||||
|
**Структура конфигурации:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiBase": "http://localhost:8080/api/v1",
|
||||||
|
"gateway": "default-gateway",
|
||||||
|
"inlineTargets": "tcp:127.0.0.1:22;tcp:192.168.1.1:80",
|
||||||
|
"delay": "1s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поля конфигурации
|
||||||
|
|
||||||
|
| Поле | Тип | Описание | По умолчанию |
|
||||||
|
|------|-----|----------|--------------|
|
||||||
|
| `apiBase` | string | URL API сервера или "internal" | `http://localhost:8080/api/v1` |
|
||||||
|
| `gateway` | string | Шлюз по умолчанию | `""` |
|
||||||
|
| `inlineTargets` | string | Цели в inline формате | `""` |
|
||||||
|
| `delay` | string | Задержка между целями | `"1s"` |
|
||||||
|
|
||||||
|
### Редактирование конфигурации
|
||||||
|
|
||||||
|
**Через интерфейс:**
|
||||||
|
|
||||||
|
1. Меню → Настройки
|
||||||
|
2. Редактирование JSON в текстовом поле
|
||||||
|
3. Кнопка "Сохранить"
|
||||||
|
|
||||||
|
**Программно:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Получить значение
|
||||||
|
const apiBase = await window.api.getConfig('apiBase');
|
||||||
|
|
||||||
|
// Установить значение
|
||||||
|
await window.api.setConfig('apiBase', 'http://new-api.com');
|
||||||
|
|
||||||
|
// Получить всю конфигурацию
|
||||||
|
const config = await window.api.getAllConfig();
|
||||||
|
|
||||||
|
// Установить всю конфигурацию
|
||||||
|
await window.api.setAllConfig(newConfig);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API контракты
|
||||||
|
|
||||||
|
### HTTP API Endpoints (для API режима)
|
||||||
|
|
||||||
|
#### 1. Выполнение простукивания
|
||||||
|
|
||||||
|
**POST** `/api/v1/knock-actions/execute`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
``` text
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Basic <base64(username:password)>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body (YAML режим):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config_yaml": "targets:\n - protocol: tcp\n host: 127.0.0.1\n ports: [22, 80]\ndelay: 1s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body (Inline режим):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targets": "tcp:127.0.0.1:22;tcp:192.168.1.1:80",
|
||||||
|
"delay": "1s",
|
||||||
|
"verbose": true,
|
||||||
|
"waitConnection": false,
|
||||||
|
"gateway": "gateway.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body (Form режим):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targets": "tcp:127.0.0.1:22;tcp:192.168.1.1:80",
|
||||||
|
"delay": "2s",
|
||||||
|
"verbose": true,
|
||||||
|
"waitConnection": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Knocking completed successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Шифрование YAML
|
||||||
|
|
||||||
|
**POST** `/api/v1/knock-actions/encrypt`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
``` text
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Basic <base64(username:password)>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"yaml": "targets:\n - protocol: tcp\n host: 127.0.0.1\n ports: [22]"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"encrypted": "ENCRYPTED:base64-encoded-data"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Расшифровка YAML
|
||||||
|
|
||||||
|
**POST** `/api/v1/knock-actions/decrypt`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
``` text
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Basic <base64(username:password)>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"encrypted": "ENCRYPTED:base64-encoded-data"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"yaml": "targets:\n - protocol: tcp\n host: 127.0.0.1\n ports: [22]"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IPC API (для локального режима)
|
||||||
|
|
||||||
|
#### Локальное простукивание
|
||||||
|
|
||||||
|
**Channel:** `knock:local`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
targets: string[], // ["tcp:127.0.0.1:22", "udp:192.168.1.1:53"]
|
||||||
|
delay: string, // "1s", "2m", "500ms"
|
||||||
|
verbose: boolean, // true/false
|
||||||
|
gateway: string // "192.168.1.1" (опционально)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
success: boolean,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
target: string,
|
||||||
|
success: boolean,
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
total: number,
|
||||||
|
successful: number,
|
||||||
|
failed: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Локальное простукивание _
|
||||||
|
|
||||||
|
### Поддерживаемые протоколы
|
||||||
|
|
||||||
|
- **TCP** - создание соединения и немедленное закрытие
|
||||||
|
- **UDP** - отправка пакета данных (fire-and-forget)
|
||||||
|
|
||||||
|
### Формат целей
|
||||||
|
|
||||||
|
``` text
|
||||||
|
protocol:host:port[:gateway]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
- `tcp:127.0.0.1:22`
|
||||||
|
- `udp:192.168.1.1:53`
|
||||||
|
- `tcp:example.com:80:gateway.com`
|
||||||
|
|
||||||
|
### Поддержка Gateway
|
||||||
|
|
||||||
|
Gateway можно указать двумя способами:
|
||||||
|
|
||||||
|
1. **В строке цели**: `tcp:host:port:gateway_ip`
|
||||||
|
2. **Глобально через поле Gateway**: используется для всех целей, если не указан в самой цели
|
||||||
|
|
||||||
|
**Приоритет gateway:**
|
||||||
|
|
||||||
|
- Gateway из строки цели имеет приоритет над глобальным
|
||||||
|
- Если gateway не указан, используется системный маршрут по умолчанию
|
||||||
|
|
||||||
|
**Примеры использования gateway:**
|
||||||
|
|
||||||
|
``` text
|
||||||
|
tcp:192.168.1.100:22:192.168.1.1 # Через конкретный gateway
|
||||||
|
tcp:127.0.0.1:22 # Системный маршрут
|
||||||
|
udp:example.com:53 # Системный маршрут
|
||||||
|
```
|
||||||
|
|
||||||
|
### Формат задержек
|
||||||
|
|
||||||
|
- `1s` - 1 секунда
|
||||||
|
- `500ms` - 500 миллисекунд (не поддерживается, используйте `0.5s`)
|
||||||
|
- `2m` - 2 минуты
|
||||||
|
- `1h` - 1 час
|
||||||
|
|
||||||
|
### Таймауты
|
||||||
|
|
||||||
|
- **TCP**: 5 секунд по умолчанию
|
||||||
|
- **UDP**: 5 секунд по умолчанию
|
||||||
|
|
||||||
|
### Примеры локального простукивания
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Простое TCP простукивание
|
||||||
|
const result = await window.api.localKnock({
|
||||||
|
targets: ["tcp:127.0.0.1:22"],
|
||||||
|
delay: "1s",
|
||||||
|
verbose: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Множественные цели
|
||||||
|
const result = await window.api.localKnock({
|
||||||
|
targets: [
|
||||||
|
"tcp:127.0.0.1:22",
|
||||||
|
"udp:192.168.1.1:53",
|
||||||
|
"tcp:example.com:80"
|
||||||
|
],
|
||||||
|
delay: "2s",
|
||||||
|
verbose: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Интерфейс пользователя
|
||||||
|
|
||||||
|
### Главное окно
|
||||||
|
|
||||||
|
#### Поля конфигурации _
|
||||||
|
|
||||||
|
- **API URL** - адрес API сервера или "internal" для локального режима
|
||||||
|
- **Gateway** - шлюз по умолчанию
|
||||||
|
- **Password** - пароль для аутентификации
|
||||||
|
|
||||||
|
#### Режимы работы _
|
||||||
|
|
||||||
|
1. **Inline** - простой текстовый формат целей
|
||||||
|
2. **YAML** - структурированная YAML конфигурация
|
||||||
|
3. **Form** - графический редактор целей
|
||||||
|
|
||||||
|
#### Inline режим
|
||||||
|
|
||||||
|
- **Targets** - цели в формате `protocol:host:port;protocol:host:port`
|
||||||
|
- **Delay** - задержка между целями
|
||||||
|
- **Verbose** - подробный вывод
|
||||||
|
- **Wait Connection** - ожидание соединения
|
||||||
|
- **Gateway** - шлюз
|
||||||
|
|
||||||
|
#### YAML режим
|
||||||
|
|
||||||
|
- **Config YAML** - YAML конфигурация
|
||||||
|
- **Server File Path** - путь к файлу на сервере
|
||||||
|
- **Encrypt/Decrypt** - шифрование/расшифровка
|
||||||
|
|
||||||
|
#### Form режим
|
||||||
|
|
||||||
|
- **Targets List** - список целей с возможностью редактирования
|
||||||
|
- **Add Target** - добавление новой цели
|
||||||
|
- **Remove** - удаление цели
|
||||||
|
|
||||||
|
### Меню приложения
|
||||||
|
|
||||||
|
#### Файл
|
||||||
|
|
||||||
|
- **Открыть файл** - загрузка YAML конфигурации
|
||||||
|
- **Сохранить как** - сохранение текущей конфигурации
|
||||||
|
- **Выход** - закрытие приложения
|
||||||
|
|
||||||
|
#### Настройки
|
||||||
|
|
||||||
|
- **Настройки** - открытие окна конфигурации
|
||||||
|
|
||||||
|
#### Справка
|
||||||
|
|
||||||
|
- **О программе** - информация о версии
|
||||||
|
- **Документация** - ссылки на документацию
|
||||||
|
|
||||||
|
### Окно настроек
|
||||||
|
|
||||||
|
#### Редактирование конфигурации _
|
||||||
|
|
||||||
|
- **JSON Editor** - многострочное поле для редактирования
|
||||||
|
- **Save** - сохранение изменений
|
||||||
|
- **Return** - возврат к главному окну
|
||||||
|
|
||||||
|
#### Валидация
|
||||||
|
|
||||||
|
- Автоматическая проверка JSON синтаксиса
|
||||||
|
- Отображение ошибок валидации
|
||||||
|
- Предотвращение сохранения некорректных данных
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Пример 1: Базовое простукивание SSH
|
||||||
|
|
||||||
|
**Цель:** Открыть SSH доступ к серверу
|
||||||
|
|
||||||
|
**Конфигурация:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiBase": "internal",
|
||||||
|
"gateway": "",
|
||||||
|
"inlineTargets": "tcp:192.168.1.100:22",
|
||||||
|
"delay": "1s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Шаги:**
|
||||||
|
|
||||||
|
1. Установить режим "Inline"
|
||||||
|
2. Ввести цель: `tcp:192.168.1.100:22`
|
||||||
|
3. Установить задержку: `1s`
|
||||||
|
4. Нажать "Выполнить"
|
||||||
|
|
||||||
|
### Пример 2: Множественные цели
|
||||||
|
|
||||||
|
**Цель:** Простучать несколько сервисов
|
||||||
|
|
||||||
|
**Конфигурация:**
|
||||||
|
|
||||||
|
``` text
|
||||||
|
tcp:server1.com:22;tcp:server1.com:80;udp:server2.com:53
|
||||||
|
```
|
||||||
|
|
||||||
|
**Задержка:** `2s`
|
||||||
|
|
||||||
|
### Пример 3: YAML конфигурация
|
||||||
|
|
||||||
|
**Файл конфигурации:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
targets:
|
||||||
|
- protocol: tcp
|
||||||
|
host: 127.0.0.1
|
||||||
|
ports: [22, 80, 443]
|
||||||
|
wait_connection: true
|
||||||
|
- protocol: udp
|
||||||
|
host: 192.168.1.1
|
||||||
|
ports: [53, 123]
|
||||||
|
delay: 1s
|
||||||
|
path: /etc/knocker/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример 4: Шифрование конфигурации
|
||||||
|
|
||||||
|
**Шаги:**
|
||||||
|
|
||||||
|
1. Создать YAML конфигурацию
|
||||||
|
2. Установить пароль
|
||||||
|
3. Нажать "Зашифровать"
|
||||||
|
4. Сохранить зашифрованный файл
|
||||||
|
|
||||||
|
### Пример 5: Локальный режим с множественными целями
|
||||||
|
|
||||||
|
**Конфигурация для локального режима:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiBase": "internal",
|
||||||
|
"inlineTargets": "tcp:127.0.0.1:22;tcp:127.0.0.1:80;udp:127.0.0.1:53",
|
||||||
|
"delay": "1s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример 6: Использование Gateway
|
||||||
|
|
||||||
|
**Простукивание через определенный интерфейс:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiBase": "internal",
|
||||||
|
"gateway": "192.168.1.1",
|
||||||
|
"inlineTargets": "tcp:192.168.1.100:22",
|
||||||
|
"delay": "1s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Смешанное использование gateway:**
|
||||||
|
|
||||||
|
``` text
|
||||||
|
tcp:127.0.0.1:22;tcp:192.168.1.100:22:192.168.1.1;udp:example.com:53
|
||||||
|
```
|
||||||
|
|
||||||
|
- Первая цель: без gateway (системный маршрут)
|
||||||
|
- Вторая цель: через gateway 192.168.1.1
|
||||||
|
- Третья цель: без gateway
|
||||||
|
|
||||||
|
Замечания по ошибкам:
|
||||||
|
- Если указан несуществующий интерфейс в `gateway`, helper вернёт критическую ошибку и код выхода 1.
|
||||||
|
- При `waitConnection: false` сетевые отказы соединения трактуются как предупреждения, но ошибки привязки к интерфейсу — всегда ошибки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Устранение неполадок
|
||||||
|
|
||||||
|
### Общие проблемы
|
||||||
|
|
||||||
|
#### Проблема: "API URL не доступен"
|
||||||
|
|
||||||
|
**Симптомы:**
|
||||||
|
|
||||||
|
- Ошибки подключения к API
|
||||||
|
- Таймауты при выполнении операций
|
||||||
|
|
||||||
|
**Решения:**
|
||||||
|
|
||||||
|
1. Проверить доступность API сервера
|
||||||
|
2. Проверить правильность URL
|
||||||
|
3. Проверить настройки файрвола
|
||||||
|
4. Переключиться в локальный режим
|
||||||
|
|
||||||
|
#### Проблема: "Неправильный пароль"
|
||||||
|
|
||||||
|
**Симптомы:**
|
||||||
|
|
||||||
|
- HTTP 401 ошибки
|
||||||
|
- Отказ в доступе при шифровании
|
||||||
|
|
||||||
|
**Решения:**
|
||||||
|
|
||||||
|
1. Проверить правильность пароля
|
||||||
|
2. Убедиться в корректности base64 кодирования
|
||||||
|
3. Проверить настройки аутентификации на сервере
|
||||||
|
|
||||||
|
#### Проблема: "Файл не найден"
|
||||||
|
|
||||||
|
**Симптомы:**
|
||||||
|
|
||||||
|
- Ошибки при открытии файлов
|
||||||
|
- "File not found" при сохранении
|
||||||
|
|
||||||
|
**Решения:**
|
||||||
|
|
||||||
|
1. Проверить права доступа к файлам
|
||||||
|
2. Убедиться в существовании директорий
|
||||||
|
3. Проверить путь к файлу
|
||||||
|
|
||||||
|
### Проблемы локального режима
|
||||||
|
|
||||||
|
#### Проблема: "No targets provided"
|
||||||
|
|
||||||
|
**Причина:** Не удалось извлечь цели из конфигурации
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
1. Проверить заполнение поля targets
|
||||||
|
2. Убедиться в корректности формата
|
||||||
|
3. Проверить режим работы (inline/yaml/form)
|
||||||
|
|
||||||
|
#### Проблема: "Unsupported protocol"
|
||||||
|
|
||||||
|
**Причина:** Использован неподдерживаемый протокол
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
- Использовать только `tcp` или `udp`
|
||||||
|
- Проверить синтаксис: `protocol:host:port`
|
||||||
|
|
||||||
|
#### Проблема: "Connection timeout"
|
||||||
|
|
||||||
|
**Причина:** Цель недоступна или заблокирована
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
1. Проверить доступность цели
|
||||||
|
2. Проверить настройки файрвола
|
||||||
|
3. Убедиться в правильности IP/порта
|
||||||
|
|
||||||
|
### Проблемы конфигурации
|
||||||
|
|
||||||
|
#### Проблема: "Invalid JSON"
|
||||||
|
|
||||||
|
**Симптомы:**
|
||||||
|
|
||||||
|
- Ошибки при сохранении настроек
|
||||||
|
- Невозможность загрузить конфигурацию
|
||||||
|
|
||||||
|
**Решения:**
|
||||||
|
|
||||||
|
1. Проверить синтаксис JSON
|
||||||
|
2. Использовать валидатор JSON
|
||||||
|
3. Проверить экранирование специальных символов
|
||||||
|
|
||||||
|
#### Проблема: "Настройки не сохраняются"
|
||||||
|
|
||||||
|
**Причина:** Проблемы с правами доступа
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
1. Проверить права записи в директорию конфигурации
|
||||||
|
2. Запустить от имени администратора (если необходимо)
|
||||||
|
3. Проверить свободное место на диске
|
||||||
|
|
||||||
|
### Диагностика
|
||||||
|
|
||||||
|
#### Логи приложения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
%APPDATA%/[app-name]/logs/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
~/Library/Logs/[app-name]/
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
~/.config/[app-name]/logs/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DevTools
|
||||||
|
|
||||||
|
1. Открыть DevTools (F12)
|
||||||
|
2. Проверить Console на ошибки
|
||||||
|
3. Проверить Network для API запросов
|
||||||
|
4. Проверить Application → Local Storage
|
||||||
|
|
||||||
|
#### Командная строка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск с отладкой
|
||||||
|
npm run dev -- --enable-logging
|
||||||
|
|
||||||
|
# Проверка переменных окружения
|
||||||
|
echo $KNOCKER_DESKTOP_API_BASE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Разработка _
|
||||||
|
|
||||||
|
### Структура проекта
|
||||||
|
|
||||||
|
``` text
|
||||||
|
desktop/
|
||||||
|
├── src/
|
||||||
|
│ ├── main/ # Main процесс
|
||||||
|
│ │ └── main.js # Основная логика
|
||||||
|
│ ├── preload/ # Preload скрипты
|
||||||
|
│ │ └── preload.js # IPC мост
|
||||||
|
│ └── renderer/ # Renderer процесс
|
||||||
|
│ ├── index.html # Главная страница
|
||||||
|
│ ├── renderer.js # UI логика
|
||||||
|
│ ├── settings.html # Страница настроек
|
||||||
|
│ └── settings.js # Логика настроек
|
||||||
|
├── package.json # Зависимости и скрипты
|
||||||
|
├── electron-builder.yml # Конфигурация сборки
|
||||||
|
└── README.md # Документация
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые файлы
|
||||||
|
|
||||||
|
#### `src/main/main.js`
|
||||||
|
|
||||||
|
- Создание и управление окнами
|
||||||
|
- IPC обработчики
|
||||||
|
- Локальное простукивание
|
||||||
|
- Файловые операции
|
||||||
|
|
||||||
|
#### `src/preload/preload.js`
|
||||||
|
|
||||||
|
- Безопасный мост между процессами
|
||||||
|
- Экспорт API в renderer
|
||||||
|
|
||||||
|
#### `src/renderer/renderer.js`
|
||||||
|
|
||||||
|
- UI логика
|
||||||
|
- Обработка пользовательского ввода
|
||||||
|
- HTTP запросы к API
|
||||||
|
|
||||||
|
### Добавление новых функций
|
||||||
|
|
||||||
|
#### 1. Новый IPC метод
|
||||||
|
|
||||||
|
**В main.js:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
ipcMain.handle('new:method', async (_e, payload) => {
|
||||||
|
// Логика метода
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**В preload.js:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
// ... существующие методы
|
||||||
|
newMethod: async (payload) => ipcRenderer.invoke('new:method', payload)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**В renderer.js:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const result = await window.api.newMethod(data);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Новый UI элемент
|
||||||
|
|
||||||
|
**В index.html:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button id="newButton">Новая функция</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**В renderer.js:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
qsi('#newButton')?.addEventListener('click', async () => {
|
||||||
|
// Логика обработки
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
#### Unit тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Интеграционные тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:integration
|
||||||
|
```
|
||||||
|
|
||||||
|
#### E2E тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сборка и деплой
|
||||||
|
|
||||||
|
#### Локальная сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Создание дистрибутивов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dist
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Автоматические релизы
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Отладка
|
||||||
|
|
||||||
|
#### DevTools _
|
||||||
|
|
||||||
|
- **Main процесс**: `--inspect` флаг
|
||||||
|
- **Renderer процесс**: F12 в приложении
|
||||||
|
|
||||||
|
#### Логирование
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
console.log('Debug info:', data);
|
||||||
|
console.error('Error:', error);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Профилирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev -- --enable-profiling
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
### Рекомендации
|
||||||
|
|
||||||
|
1. **Пароли**: Используйте сильные пароли для аутентификации
|
||||||
|
2. **Сеть**: Ограничьте доступ к API серверу
|
||||||
|
3. **Файлы**: Не храните пароли в открытом виде
|
||||||
|
4. **Обновления**: Регулярно обновляйте приложение
|
||||||
|
|
||||||
|
### Ограничения
|
||||||
|
|
||||||
|
- Локальное простукивание выполняется с правами пользователя
|
||||||
|
- Не требует root/administrator прав
|
||||||
|
- Подчиняется системным ограничениям сетевого доступа
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
### Контакты
|
||||||
|
|
||||||
|
- **Документация**: [LOCAL_KNOCKING.md](./LOCAL_KNOCKING.md)
|
||||||
|
- **Исходный код**: [GitHub Repository]
|
||||||
|
- **Issues**: [GitHub Issues]
|
||||||
|
|
||||||
|
### Версии
|
||||||
|
|
||||||
|
- **Текущая версия**: 1.0
|
||||||
|
- **Electron**: v28+
|
||||||
|
- **Node.js**: v18+
|
||||||
|
|
||||||
|
### Лицензия
|
||||||
|
|
||||||
|
[Указать лицензию]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Версия документации**: 1.0
|
||||||
|
**Дата создания**: 2024
|
||||||
|
**Совместимость**: Electron Desktop App v1.0+
|
||||||
4923
desktop/package-lock.json
generated
Normal file
56
desktop/package.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "desktop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "src/main/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dev": "npm run rust:build && npm run go:build && electron .",
|
||||||
|
"build": "npm run rust:build && npm run go:build && electron-builder",
|
||||||
|
"build:win": "electron-builder --win",
|
||||||
|
"build:linux": "electron-builder --linux",
|
||||||
|
"build:mac": "electron-builder --mac",
|
||||||
|
"dist": "electron-builder --publish=never",
|
||||||
|
"pack": "electron-builder --dir",
|
||||||
|
"test": "echo \"No tests\" && exit 0",
|
||||||
|
"go:build": "bash -lc 'mkdir -p bin && cd ../back && go build -o ../desktop/bin/knock-local ./cmd/knock-local'",
|
||||||
|
"rust:build": "bash -lc 'mkdir -p bin && cd ../rust-knocker && cargo build --release && cp target/release/knock-local ../desktop/bin/knock-local-rust'"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"build": {
|
||||||
|
"appId": "com.knocker.desktop",
|
||||||
|
"productName": "Knocker Desktop",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/**/*",
|
||||||
|
"node_modules/**/*",
|
||||||
|
"bin/**/*"
|
||||||
|
],
|
||||||
|
"extraResources": [{ "from": "bin", "to": "bin", "filter": ["**/*"] }],
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"icon": "assets/icon.ico"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage",
|
||||||
|
"icon": "assets/icon.png"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg",
|
||||||
|
"icon": "assets/icon.icns"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^28.3.3",
|
||||||
|
"electron-builder": "^26.0.12"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"js-yaml": "^4.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
666
desktop/src/main/main.js
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
const { app, BrowserWindow, ipcMain, dialog, shell, Menu } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const net = require('net');
|
||||||
|
const dgram = require('dgram');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
let mainWindow = null;
|
||||||
|
let settingsWindow = null;
|
||||||
|
|
||||||
|
// --- Persistent config (userData/config.json) ---
|
||||||
|
let configCache = null;
|
||||||
|
function getConfigPath() {
|
||||||
|
return path.join(app.getPath('userData'), 'config.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
if (configCache) return configCache;
|
||||||
|
const cfgPath = getConfigPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(cfgPath)) {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||||
|
configCache = parsed || {};
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to read config file:', e);
|
||||||
|
}
|
||||||
|
configCache = {};
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig(partial) {
|
||||||
|
const current = loadConfig();
|
||||||
|
const next = { ...current, ...partial };
|
||||||
|
configCache = next;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true });
|
||||||
|
fs.writeFileSync(getConfigPath(), JSON.stringify(next, null, 2), 'utf-8');
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save config file:', e);
|
||||||
|
return { ok: false, error: (e?.message) || String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1100,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexPath = path.join(__dirname, '../renderer/index.html');
|
||||||
|
mainWindow.loadFile(indexPath);
|
||||||
|
|
||||||
|
// Включаем DevTools для разработки
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создаем меню
|
||||||
|
createMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSettingsWindow() {
|
||||||
|
if (settingsWindow) {
|
||||||
|
settingsWindow.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsWindow = new BrowserWindow({
|
||||||
|
width: 600,
|
||||||
|
height: 720,
|
||||||
|
parent: mainWindow,
|
||||||
|
modal: true,
|
||||||
|
resizable: true,
|
||||||
|
closable: true,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsPath = path.join(__dirname, '../renderer/settings.html');
|
||||||
|
settingsWindow.loadFile(settingsPath);
|
||||||
|
|
||||||
|
settingsWindow.on('closed', () => {
|
||||||
|
settingsWindow = null;
|
||||||
|
// Возвращаем фокус на главное окно
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMenu() {
|
||||||
|
const template = [
|
||||||
|
{
|
||||||
|
label: 'Файл',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Настройки',
|
||||||
|
accelerator: 'CmdOrCtrl+,',
|
||||||
|
click: createSettingsWindow
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Выход',
|
||||||
|
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
|
||||||
|
click: () => {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Вид',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'reload', label: 'Перезагрузить' },
|
||||||
|
{ role: 'forceReload', label: 'Принудительная перезагрузка' },
|
||||||
|
{ role: 'toggleDevTools', label: 'Инструменты разработчика' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'resetZoom', label: 'Сбросить масштаб' },
|
||||||
|
{ role: 'zoomIn', label: 'Увеличить' },
|
||||||
|
{ role: 'zoomOut', label: 'Уменьшить' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'togglefullscreen', label: 'Полный экран' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Окно',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'minimize', label: 'Свернуть' },
|
||||||
|
{ role: 'close', label: 'Закрыть' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
template.unshift({
|
||||||
|
label: app.getName(),
|
||||||
|
submenu: [
|
||||||
|
{ role: 'about', label: 'О программе' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'services', label: 'Сервисы' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'hide', label: 'Скрыть' },
|
||||||
|
{ role: 'hideOthers', label: 'Скрыть остальные' },
|
||||||
|
{ role: 'unhide', label: 'Показать все' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'quit', label: 'Выход' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate(template);
|
||||||
|
Menu.setApplicationMenu(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Глобальные обработчики ошибок
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('Uncaught Exception in main process:', error);
|
||||||
|
// Не завершаем приложение, просто логируем
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
console.error('Unhandled Rejection in main process:', reason);
|
||||||
|
// Не завершаем приложение, просто логируем
|
||||||
|
});
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow();
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Config IPC ---
|
||||||
|
ipcMain.handle('config:get', async (_e, key) => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
if (key) return cfg[key];
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('config:set', async (_e, key, value) => {
|
||||||
|
return saveConfig({ [key]: value });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('config:getAll', async () => {
|
||||||
|
return loadConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('config:setAll', async (_e, newConfig) => {
|
||||||
|
return saveConfig(newConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('settings:close', () => {
|
||||||
|
if (settingsWindow) {
|
||||||
|
settingsWindow.close();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: false, error: 'Settings window not found' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Local Port Knocking Implementation ---
|
||||||
|
function parseTarget(targetStr) {
|
||||||
|
const parts = targetStr.split(':');
|
||||||
|
if (parts.length < 3) {
|
||||||
|
throw new Error(`Invalid target format: ${targetStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: parts[0]?.toLowerCase() || 'tcp',
|
||||||
|
host: parts[1] || '127.0.0.1',
|
||||||
|
port: parseInt(parts[2]) || 22,
|
||||||
|
gateway: parts[3] || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDelay(delayStr) {
|
||||||
|
const match = delayStr?.match(/^(\d+)([smh]?)$/);
|
||||||
|
if (!match) return 1000; // default 1 second
|
||||||
|
|
||||||
|
const value = parseInt(match[1]);
|
||||||
|
const unit = match[2] || 's';
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 's': return value * 1000;
|
||||||
|
case 'm': return value * 60 * 1000;
|
||||||
|
case 'h': return value * 60 * 60 * 1000;
|
||||||
|
default: return value * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function knockTcp(host, port, timeout = 5000, gateway = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
function safeResolve(result) {
|
||||||
|
if (resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolved = true;
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors during cleanup
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.setTimeout(timeout);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
const gatewayInfo = gateway ? ` via ${gateway}` : '';
|
||||||
|
const localAddr = socket.localAddress;
|
||||||
|
const localPort = socket.localPort;
|
||||||
|
console.log(`TCP connected from ${localAddr}:${localPort} to ${host}:${port}`);
|
||||||
|
safeResolve({ success: true, message: `TCP connection to ${host}:${port}${gatewayInfo} successful (from ${localAddr}:${localPort})` });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
const gatewayInfo = gateway ? ` via ${gateway}` : '';
|
||||||
|
safeResolve({ success: false, message: `TCP connection to ${host}:${port}${gatewayInfo} timeout` });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
const gatewayInfo = gateway ? ` via ${gateway}` : '';
|
||||||
|
safeResolve({ success: false, message: `TCP connection to ${host}:${port}${gatewayInfo} failed: ${err.message}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
// Socket was closed, nothing to do
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (gateway?.trim()) {
|
||||||
|
// Для TCP используем localAddress в connect() для обхода VPN
|
||||||
|
console.log(`Using localAddress ${gateway.trim()} to bypass VPN/tunnel`);
|
||||||
|
socket.connect({
|
||||||
|
port,
|
||||||
|
host: host,
|
||||||
|
localAddress: gateway.trim()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Обычное подключение без привязки
|
||||||
|
socket.connect(port, host);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const gatewayInfo = gateway ? ` via ${gateway}` : '';
|
||||||
|
safeResolve({ success: false, message: `TCP connection to ${host}:${port}${gatewayInfo} failed: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function knockUdp(host, port, timeout = 5000, gateway = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
const message = Buffer.from('knock');
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
function safeResolve(result) {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
socket.close();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
const gatewayInfo = gateway ? ` via ${gateway}` : '';
|
||||||
|
safeResolve({ success: false, message: `UDP packet to ${host}:${port}${gatewayInfo} failed: ${err.message}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если указан gateway, привязываем сокет к локальному адресу для обхода VPN/туннелей
|
||||||
|
if (gateway && gateway.trim()) {
|
||||||
|
try {
|
||||||
|
socket.bind(0, gateway.trim());
|
||||||
|
console.log(`UDP socket bound to localAddress ${gateway.trim()} to bypass VPN/tunnel`);
|
||||||
|
} catch (bindError) {
|
||||||
|
const gatewayInfo = gateway ? ` via ${gateway}` : '';
|
||||||
|
safeResolve({ success: false, message: `UDP socket bind to ${gateway}${gatewayInfo} failed: ${bindError.message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send(message, 0, message.length, port, host, (err) => {
|
||||||
|
if (err) {
|
||||||
|
const gatewayInfo = gateway ? ` via ${gateway}` : '';
|
||||||
|
safeResolve({ success: false, message: `UDP packet to ${host}:${port}${gatewayInfo} failed: ${err.message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDP is fire-and-forget, so we consider it successful if we can send
|
||||||
|
const gatewayInfo = gateway ? ` via ${gateway}` : '';
|
||||||
|
const localAddr = socket.address()?.address;
|
||||||
|
const localPort = socket.address()?.port;
|
||||||
|
console.log(`UDP packet sent from ${localAddr}:${localPort} to ${host}:${port}`);
|
||||||
|
safeResolve({ success: true, message: `UDP packet sent to ${host}:${port}${gatewayInfo} (from ${localAddr}:${localPort})` });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set timeout for UDP operations
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const gatewayInfo = gateway ? ` via ${gateway}` : '';
|
||||||
|
safeResolve({ success: true, message: `UDP packet sent to ${host}:${port}${gatewayInfo} (timeout reached)` });
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
// Clean up timeout if socket resolves earlier
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performLocalKnock(targets, delay, verbose = true, gateway = null) {
|
||||||
|
const results = [];
|
||||||
|
const delayMs = parseDelay(delay);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < targets.length; i++) {
|
||||||
|
const targetStr = targets[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = parseTarget(targetStr);
|
||||||
|
|
||||||
|
// Используем gateway из цели или глобальный gateway
|
||||||
|
const effectiveGateway = target.gateway || gateway;
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`Knocking ${target.protocol.toUpperCase()} ${target.host}:${target.port}${effectiveGateway ? ` via ${effectiveGateway}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
if (target.protocol === 'tcp') {
|
||||||
|
result = await knockTcp(target.host, target.port, 5000, effectiveGateway);
|
||||||
|
} else if (target.protocol === 'udp') {
|
||||||
|
result = await knockUdp(target.host, target.port, 5000, effectiveGateway);
|
||||||
|
} else {
|
||||||
|
result = { success: false, message: `Unsupported protocol: ${target.protocol}` };
|
||||||
|
}
|
||||||
|
} catch (knockError) {
|
||||||
|
result = {
|
||||||
|
success: false,
|
||||||
|
message: `Knock operation failed: ${knockError.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
target: targetStr,
|
||||||
|
...result
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add delay between targets (except for the last one)
|
||||||
|
if (i < targets.length - 1 && delayMs > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing target ${targetStr}:`, error);
|
||||||
|
results.push({
|
||||||
|
target: targetStr,
|
||||||
|
success: false,
|
||||||
|
message: `Error: ${error.message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Critical error in performLocalKnock:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диагностика сетевых интерфейсов
|
||||||
|
ipcMain.handle('network:interfaces', async () => {
|
||||||
|
try {
|
||||||
|
const interfaces = os.networkInterfaces();
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||||
|
result[name] = addrs.map(addr => ({
|
||||||
|
address: addr.address,
|
||||||
|
family: addr.family,
|
||||||
|
internal: addr.internal,
|
||||||
|
mac: addr.mac
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, interfaces: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Тест подключения с конкретным localAddress
|
||||||
|
ipcMain.handle('network:test-connection', async (_e, payload) => {
|
||||||
|
try {
|
||||||
|
const { host, port, localAddress } = payload;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
function safeResolve(result) {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.setTimeout(5000);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
const localAddr = socket.localAddress;
|
||||||
|
const localPort = socket.localPort;
|
||||||
|
safeResolve({
|
||||||
|
success: true,
|
||||||
|
message: `Connection successful from ${localAddr}:${localPort}`,
|
||||||
|
localAddress: localAddr,
|
||||||
|
localPort: localPort
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
safeResolve({ success: false, error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
safeResolve({ success: false, error: 'Connection timeout' });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (localAddress) {
|
||||||
|
console.log(`Testing connection to ${host}:${port} with localAddress ${localAddress}`);
|
||||||
|
socket.connect({
|
||||||
|
port: port,
|
||||||
|
host: host,
|
||||||
|
localAddress: localAddress
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.connect(port, host);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
safeResolve({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('knock:local', async (_e, payload) => {
|
||||||
|
try {
|
||||||
|
// Валидация входных данных
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return { success: false, error: 'Invalid payload provided' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { targets, delay, verbose, gateway } = payload;
|
||||||
|
|
||||||
|
if (!targets || !Array.isArray(targets) || targets.length === 0) {
|
||||||
|
return { success: false, error: 'No targets provided' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация каждого target
|
||||||
|
const validTargets = targets.filter(target => {
|
||||||
|
return typeof target === 'string' && target.trim().length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validTargets.length === 0) {
|
||||||
|
return { success: false, error: 'No valid targets provided' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если задан gateway, используем Go-хелпер (поддерживает SO_BINDTODEVICE)
|
||||||
|
if ((gateway && String(gateway).trim()) || validTargets.some(t => t.split(':').length >= 4)) {
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
// Ищем собранный бинарь внутри Electron-пакета
|
||||||
|
// Сначала пробуем Rust версию, потом Go версию
|
||||||
|
const devRustBin = path.resolve(__dirname, '../../bin/knock-local-rust');
|
||||||
|
const prodRustBin = path.resolve(process.resourcesPath || path.resolve(__dirname, '../../'), 'bin/knock-local-rust');
|
||||||
|
const devGoBin = path.resolve(__dirname, '../../bin/knock-local');
|
||||||
|
const prodGoBin = path.resolve(process.resourcesPath || path.resolve(__dirname, '../../'), 'bin/knock-local');
|
||||||
|
|
||||||
|
let helperExec;
|
||||||
|
if (fs.existsSync(devRustBin)) {
|
||||||
|
helperExec = devRustBin;
|
||||||
|
console.log('Using Rust knock-local helper (dev)');
|
||||||
|
} else if (fs.existsSync(prodRustBin)) {
|
||||||
|
helperExec = prodRustBin;
|
||||||
|
console.log('Using Rust knock-local helper (prod)');
|
||||||
|
} else if (fs.existsSync(devGoBin)) {
|
||||||
|
helperExec = devGoBin;
|
||||||
|
console.log('Using Go knock-local helper (dev)');
|
||||||
|
} else {
|
||||||
|
helperExec = prodGoBin;
|
||||||
|
console.log('Using Go knock-local helper (prod)');
|
||||||
|
}
|
||||||
|
const req = {
|
||||||
|
targets: validTargets,
|
||||||
|
delay: delay || '1s',
|
||||||
|
// Принудительно отключаем verbose у хелпера, чтобы stdout был чисто JSON
|
||||||
|
verbose: false,
|
||||||
|
gateway: gateway || ''
|
||||||
|
};
|
||||||
|
const input = JSON.stringify(req);
|
||||||
|
const child = spawn(helperExec, [], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', d => { stdout += d.toString(); });
|
||||||
|
child.stderr.on('data', d => { stderr += d.toString(); });
|
||||||
|
child.stdin.write(input);
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
// Таймаут на 15 секунд - вдруг что-то пойдёт не так
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
}, 15_000);
|
||||||
|
const code = await new Promise(resolve => child.on('close', resolve));
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (code !== 0) {
|
||||||
|
return { success: false, error: `go helper exited with code ${code}: ${stderr || stdout}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Извлекаем последнюю JSON-строку из stdout (в случае если есть текстовые логи)
|
||||||
|
const lines = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
||||||
|
const jsonLine = [...lines].reverse().find(l => l.startsWith('{') && l.endsWith('}')) || stdout.trim();
|
||||||
|
const parsed = JSON.parse(jsonLine);
|
||||||
|
if (parsed?.success) {
|
||||||
|
return { success: true, results: [], summary: { total: validTargets.length, successful: validTargets.length, failed: 0 } };
|
||||||
|
}
|
||||||
|
return { success: false, error: parsed?.error || 'unknown helper error' };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: `failed to parse helper output: ${e.message}`, raw: stdout };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await performLocalKnock(validTargets, delay || '1s', Boolean(verbose), gateway || null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
results: results,
|
||||||
|
summary: {
|
||||||
|
total: results.length,
|
||||||
|
successful: results.filter(r => r.success).length,
|
||||||
|
failed: results.filter(r => !r.success).length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Local knock error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File dialogs and fs operations
|
||||||
|
ipcMain.handle('file:open', async () => {
|
||||||
|
const res = await dialog.showOpenDialog({
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [ { name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] } ]
|
||||||
|
});
|
||||||
|
if (res.canceled || res.filePaths.length === 0) return { canceled: true };
|
||||||
|
const filePath = res.filePaths[0];
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { canceled: false, filePath, content };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('file:saveAs', async (_e, payload) => {
|
||||||
|
const res = await dialog.showSaveDialog({
|
||||||
|
defaultPath: (payload && payload.suggestedName) || 'config.yaml',
|
||||||
|
filters: [ { name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] } ]
|
||||||
|
});
|
||||||
|
if (res.canceled || !res.filePath) return { canceled: true };
|
||||||
|
fs.writeFileSync(res.filePath, payload.content, 'utf-8');
|
||||||
|
return { canceled: false, filePath: res.filePath };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('file:saveToPath', async (_e, payload) => {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(payload.filePath, payload.content, 'utf-8');
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e && e.message) || String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('os:revealInFolder', async (_e, filePath) => {
|
||||||
|
try {
|
||||||
|
shell.showItemInFolder(filePath);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e && e.message) || String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
23
desktop/src/preload/preload.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
openFile: async () => ipcRenderer.invoke('file:open'),
|
||||||
|
saveAs: async (payload) => ipcRenderer.invoke('file:saveAs', payload),
|
||||||
|
saveToPath: async (payload) => ipcRenderer.invoke('file:saveToPath', payload),
|
||||||
|
revealInFolder: async (filePath) => ipcRenderer.invoke('os:revealInFolder', filePath),
|
||||||
|
getConfig: async (key) => ipcRenderer.invoke('config:get', key),
|
||||||
|
setConfig: async (key, value) => ipcRenderer.invoke('config:set', key, value),
|
||||||
|
getAllConfig: async () => ipcRenderer.invoke('config:getAll'),
|
||||||
|
setAllConfig: async (config) => ipcRenderer.invoke('config:setAll', config),
|
||||||
|
closeSettings: async () => ipcRenderer.invoke('settings:close'),
|
||||||
|
localKnock: async (payload) => ipcRenderer.invoke('knock:local', payload),
|
||||||
|
getNetworkInterfaces: async () => ipcRenderer.invoke('network:interfaces'),
|
||||||
|
testConnection: async (payload) => ipcRenderer.invoke('network:test-connection', payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пробрасываем конфигурацию в рендерер (безопасно)
|
||||||
|
contextBridge.exposeInMainWorld('config', {
|
||||||
|
apiBase: process.env.KNOCKER_DESKTOP_API_BASE || 'http://localhost:8080/api/v1'
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
1
desktop/src/renderer/assets/logo.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Port kicker
|
||||||
100
desktop/src/renderer/index.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Knocker Desktop</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<header>
|
||||||
|
<h1 style="font-size: 2.5rem; margin-bottom: 1rem">
|
||||||
|
Port Knocker - Desktop
|
||||||
|
</h1>
|
||||||
|
<div class="modes">
|
||||||
|
<label
|
||||||
|
><input type="radio" name="mode" value="inline" checked />
|
||||||
|
Inline</label
|
||||||
|
>
|
||||||
|
<label><input type="radio" name="mode" value="yaml" /> YAML</label>
|
||||||
|
<label><input type="radio" name="mode" value="form" /> Form</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="constant-section" class="constant-mode-section">
|
||||||
|
<div class="row">
|
||||||
|
<label style="min-width: 100px">Api URL</label>
|
||||||
|
<input id="apiUrl" type="text" placeholder="Введите api url" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label style="min-width: 100px">Пароль</label>
|
||||||
|
<input id="password" type="password" placeholder="Введите пароль" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label style="min-width: 100px">Задержка</label>
|
||||||
|
<input id="delay" type="text" value="1s" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="inline-section" class="mode-section">
|
||||||
|
<div class="row">
|
||||||
|
<label style="min-width: 100px">Цели</label>
|
||||||
|
<input id="targets" type="text" value="tcp:127.0.0.1:22" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label style="min-width: 100px">Шлюз: </label>
|
||||||
|
<input id="gateway" type="text" placeholder="опционально" />
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top: 1rem">
|
||||||
|
<label
|
||||||
|
><input id="verbose" type="checkbox" checked /> Подробный
|
||||||
|
вывод</label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
><input id="waitConnection" type="checkbox" /> Ждать
|
||||||
|
соединение</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="yaml-section" class="mode-section hidden">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="openFile">Открыть файл…</button>
|
||||||
|
<button id="saveFile">Сохранить как…</button>
|
||||||
|
<input
|
||||||
|
id="serverFilePath"
|
||||||
|
type="text"
|
||||||
|
placeholder="server file path (path в YAML)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="configYAML"
|
||||||
|
placeholder="Вставьте YAML или откройте файл"
|
||||||
|
></textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="form-section" class="mode-section hidden">
|
||||||
|
<div id="targetsList"></div>
|
||||||
|
<button id="addTarget">Добавить цель</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="row" style="width: 100%; margin-top: 1rem">
|
||||||
|
<button style="width: 100%" id="execute">Выполнить</button>
|
||||||
|
</div>
|
||||||
|
<div class="row hidden" id="encrypt-decrypt-row" style="width: 100%; margin-top: 1rem">
|
||||||
|
<button style="width: 50%" id="encrypt">Зашифровать</button>
|
||||||
|
<button style="width: 50%" id="decrypt">Расшифровать</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="width: 100%; margin-top: 1rem">
|
||||||
|
<span id="status"></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="../../node_modules/js-yaml/dist/js-yaml.min.js"></script>
|
||||||
|
<script src="./renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
536
desktop/src/renderer/renderer.js
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
(() => {
|
||||||
|
// Глобальные обработчики ошибок в renderer
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
console.error('Global error in renderer:', event.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
console.error('Unhandled promise rejection in renderer:', event.reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
let apiBase = window.config?.apiBase || "http://localhost:8080/api/v1";
|
||||||
|
|
||||||
|
const qs = (sel) => document.querySelector(sel);
|
||||||
|
const qsi = (sel) => document.querySelector(sel);
|
||||||
|
const qst = (sel) => document.querySelector(sel);
|
||||||
|
const yaml = window.jsyaml;
|
||||||
|
|
||||||
|
function setMode(mode) {
|
||||||
|
["inline", "yaml", "form"].forEach((m) => {
|
||||||
|
const el = qs(`#${m}-section`);
|
||||||
|
if (el) el.classList.toggle("hidden", m !== mode);
|
||||||
|
const encryptDecryptRow = qs("#encrypt-decrypt-row");
|
||||||
|
|
||||||
|
if (encryptDecryptRow) {
|
||||||
|
encryptDecryptRow.classList.toggle("hidden", mode !== "yaml");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicAuthHeader(password) {
|
||||||
|
const token = btoa(`knocker:${password}`);
|
||||||
|
return { Authorization: `Basic ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(msg) {
|
||||||
|
const el = qs("#status");
|
||||||
|
if (el) {
|
||||||
|
el.textContent = msg;
|
||||||
|
setTimeout(() => {
|
||||||
|
el.textContent = "";
|
||||||
|
}, 5000); // Очищаем через 5 секунд
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = [{ protocol: "tcp", host: "127.0.0.1", port: 22, gateway: "" }];
|
||||||
|
function renderTargets() {
|
||||||
|
const list = qs("#targetsList");
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = "";
|
||||||
|
targets.forEach((t, idx) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "target-row";
|
||||||
|
row.innerHTML = `
|
||||||
|
<select data-k="protocol">
|
||||||
|
<option value="tcp" ${t.protocol === "tcp" ? "selected" : ""
|
||||||
|
}>tcp</option>
|
||||||
|
<option value="udp" ${t.protocol === "udp" ? "selected" : ""
|
||||||
|
}>udp</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" data-k="host" value="${t.host}" />
|
||||||
|
<input type="number" data-k="port" value="${t.port}" />
|
||||||
|
<input type="text" data-k="gateway" value="${t.gateway || ""
|
||||||
|
}" placeholder="gateway (опц.)" />
|
||||||
|
<button class="remove" data-idx="${idx}">Удалить</button>`;
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeFormTargetsToInline() {
|
||||||
|
return targets
|
||||||
|
.map(
|
||||||
|
(t) =>
|
||||||
|
`${t.protocol}:${t.host}:${t.port}${t.gateway ? `:${t.gateway}` : ""}`
|
||||||
|
)
|
||||||
|
.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertInlineToYaml(targetsStr, delay, waitConnection) {
|
||||||
|
const entries = (targetsStr || "").split(";").filter(Boolean);
|
||||||
|
const config = {
|
||||||
|
targets: entries.map((e) => {
|
||||||
|
const parts = e.split(":");
|
||||||
|
const protocol = parts[0] || "tcp";
|
||||||
|
const host = parts[1] || "127.0.0.1";
|
||||||
|
const port = parseInt(parts[2] || "22", 10);
|
||||||
|
return {
|
||||||
|
protocol,
|
||||||
|
host,
|
||||||
|
ports: [port],
|
||||||
|
wait_connection: !!waitConnection,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
delay: delay || "1s",
|
||||||
|
};
|
||||||
|
return yaml.dump(config, { lineWidth: 120 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertYamlToInline(yamlText) {
|
||||||
|
if (!yamlText.trim())
|
||||||
|
return {
|
||||||
|
targets: "tcp:127.0.0.1:22",
|
||||||
|
delay: "1s",
|
||||||
|
waitConnection: false,
|
||||||
|
};
|
||||||
|
const config = yaml.load(yamlText) || {};
|
||||||
|
const list = [];
|
||||||
|
(config.targets || []).forEach((t) => {
|
||||||
|
const protocol = t.protocol || "tcp";
|
||||||
|
const host = t.host || "127.0.0.1";
|
||||||
|
const ports = t.ports || [t.port] || [22];
|
||||||
|
(Array.isArray(ports) ? ports : [ports]).forEach((p) =>
|
||||||
|
list.push(`${protocol}:${host}:${p}`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
targets: list.join(";"),
|
||||||
|
delay: config.delay || "1s",
|
||||||
|
waitConnection: !!config.targets?.[0]?.wait_connection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPathFromYaml(text) {
|
||||||
|
try {
|
||||||
|
const doc = yaml.load(text);
|
||||||
|
if (doc && typeof doc === "object" && typeof doc.path === "string")
|
||||||
|
return doc.path;
|
||||||
|
} catch { }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
function patchYamlPath(text, newPath) {
|
||||||
|
try {
|
||||||
|
const doc = text.trim() ? yaml.load(text) : {};
|
||||||
|
if (doc && typeof doc === "object") {
|
||||||
|
doc.path = newPath || "";
|
||||||
|
return yaml.dump(doc, { lineWidth: 120 });
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
function isEncryptedYaml(text) {
|
||||||
|
return (text || "").trim().startsWith("ENCRYPTED:");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для обновления конфига из настроек
|
||||||
|
function updateConfigFromSettings() {
|
||||||
|
window.api.getConfig('apiBase')
|
||||||
|
.then((saved) => {
|
||||||
|
if (typeof saved === 'string' && saved.trim()) {
|
||||||
|
apiBase = saved;
|
||||||
|
if (qsi('#apiUrl')) {
|
||||||
|
qsi('#apiUrl').value = apiBase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
|
||||||
|
window.api.getConfig('gateway')
|
||||||
|
.then((saved) => {
|
||||||
|
if (qsi('#gateway')) {
|
||||||
|
qsi('#gateway').value = saved || '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
|
||||||
|
window.api.getConfig('inlineTargets')
|
||||||
|
.then((saved) => {
|
||||||
|
if (qsi('#targets')) {
|
||||||
|
qsi('#targets').value = saved || '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
|
||||||
|
window.api.getConfig('delay')
|
||||||
|
.then((saved) => {
|
||||||
|
if (qsi('#delay')) {
|
||||||
|
qsi('#delay').value = saved || '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
// событие возникающее когда загружается страница основная приложения
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
document.querySelectorAll('input[name="mode"]').forEach((r) => {
|
||||||
|
r.addEventListener("change", (e) => setMode(e?.target?.value || ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициализация/восстановление apiBase из конфига
|
||||||
|
window.api.getConfig('apiBase')
|
||||||
|
.then((saved) => {
|
||||||
|
if (typeof saved === 'string' && saved.trim()) {
|
||||||
|
apiBase = saved;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { })
|
||||||
|
.finally(() => {
|
||||||
|
if (qsi('#apiUrl')) {
|
||||||
|
qsi('#apiUrl').value = apiBase;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сохранение apiBase при изменении поля
|
||||||
|
qsi('#apiUrl')?.addEventListener('change', async () => {
|
||||||
|
const val = qsi('#apiUrl').value.trim();
|
||||||
|
if (!val) return;
|
||||||
|
apiBase = val;
|
||||||
|
try { await window.api.setConfig('apiBase', val); } catch { }
|
||||||
|
updateStatus('API URL сохранён');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициализация/восстановление gateway из конфига
|
||||||
|
window.api.getConfig('gateway')
|
||||||
|
.then((saved) => {
|
||||||
|
if (qsi('#gateway')) {
|
||||||
|
qsi('#gateway').value = saved || '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
|
||||||
|
// Сохранение Gateway при изменении поля
|
||||||
|
qsi('#gateway')?.addEventListener('change', async () => {
|
||||||
|
const val = qsi('#gateway').value.trim();
|
||||||
|
try { await window.api.setConfig('gateway', val); } catch { }
|
||||||
|
updateStatus('Gateway сохранён');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициализация/восстановление inlineTargets из конфига
|
||||||
|
window.api.getConfig('inlineTargets')
|
||||||
|
.then((saved) => {
|
||||||
|
if (qsi('#targets')) {
|
||||||
|
qsi('#targets').value = saved || '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
|
||||||
|
// Сохранение inlineTargets при изменении поля
|
||||||
|
qsi('#targets')?.addEventListener('change', async () => {
|
||||||
|
const val = qsi('#targets').value.trim();
|
||||||
|
try { await window.api.setConfig('inlineTargets', val); } catch { }
|
||||||
|
updateStatus('inlineTargets сохранёны');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициализация/восстановление delay из конфига
|
||||||
|
window.api.getConfig('delay')
|
||||||
|
.then((saved) => {
|
||||||
|
if (qsi('#delay')) {
|
||||||
|
qsi('#delay').value = saved || '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
|
||||||
|
// Сохранение delay при изменении поля
|
||||||
|
qsi('#delay')?.addEventListener('change', async () => {
|
||||||
|
const val = qsi('#delay').value.trim();
|
||||||
|
try { await window.api.setConfig('delay', val); } catch { }
|
||||||
|
updateStatus('Задержка сохранёна');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
qsi("#addTarget")?.addEventListener("click", () => {
|
||||||
|
targets.push({ protocol: "tcp", host: "127.0.0.1", port: 22 });
|
||||||
|
renderTargets();
|
||||||
|
});
|
||||||
|
|
||||||
|
qs("#targetsList")?.addEventListener("input", (e) => {
|
||||||
|
const row = e.target.closest(".target-row");
|
||||||
|
if (!row) return;
|
||||||
|
const idx = Array.from(row.parentElement.children).indexOf(row);
|
||||||
|
const key = e.target.getAttribute("data-k");
|
||||||
|
if (idx >= 0 && key) {
|
||||||
|
const val =
|
||||||
|
e.target.type === "number" ? Number(e.target.value) : e.target.value;
|
||||||
|
targets[idx][key] = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
qs("#targetsList")?.addEventListener("click", (e) => {
|
||||||
|
if (!e.target.classList.contains("remove")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = e.target.closest(".target-row");
|
||||||
|
const idx = Array.from(row.parentElement.children).indexOf(row);
|
||||||
|
if (idx >= 0) {
|
||||||
|
targets.splice(idx, 1);
|
||||||
|
renderTargets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
qs("#openFile")?.addEventListener("click", async () => {
|
||||||
|
const res = await window.api.openFile();
|
||||||
|
if (!(!res.canceled && res.content !== undefined)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qst("#configYAML").value = res.content;
|
||||||
|
const p = extractPathFromYaml(res.content);
|
||||||
|
qsi("#serverFilePath").value = p || "";
|
||||||
|
updateStatus(`Открыт файл: ${res.filePath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
qs("#saveFile")?.addEventListener("click", async () => {
|
||||||
|
const content = qst("#configYAML").value;
|
||||||
|
const suggested = content.trim().startsWith("ENCRYPTED:")
|
||||||
|
? "config.encrypted"
|
||||||
|
: "config.yaml";
|
||||||
|
const res = await window.api.saveAs({
|
||||||
|
suggestedName: suggested,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
if (!res.canceled && res.filePath) {
|
||||||
|
updateStatus(`Сохранено: ${res.filePath}`);
|
||||||
|
await window.api.revealInFolder(res.filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
qsi("#serverFilePath")?.addEventListener("input", () => {
|
||||||
|
const newPath = qsi("#serverFilePath").value;
|
||||||
|
const current = qst("#configYAML").value;
|
||||||
|
qst("#configYAML").value = patchYamlPath(current, newPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
qs("#execute")?.addEventListener("click", async () => {
|
||||||
|
updateStatus("Выполнение…");
|
||||||
|
const password = qsi("#password").value;
|
||||||
|
const mode = document.querySelector('input[name="mode"]:checked')?.value || '';
|
||||||
|
|
||||||
|
// Проверяем, нужно ли использовать локальное простукивание
|
||||||
|
const useLocalKnock = !apiBase || apiBase.trim() === '' || apiBase === 'internal';
|
||||||
|
|
||||||
|
if (useLocalKnock) {
|
||||||
|
// Локальное простукивание через Node.js
|
||||||
|
try {
|
||||||
|
let targets = [];
|
||||||
|
let delay = qsi("#delay").value || '1s';
|
||||||
|
const verbose = qsi("#verbose").checked;
|
||||||
|
|
||||||
|
if (mode === "inline") {
|
||||||
|
targets = qsi("#targets").value.split(';').filter(t => t.trim());
|
||||||
|
} else if (mode === "form") {
|
||||||
|
targets = [serializeFormTargetsToInline()];
|
||||||
|
} else if (mode === "yaml") {
|
||||||
|
// Для YAML режима извлекаем targets из YAML
|
||||||
|
const yamlContent = qst("#configYAML").value;
|
||||||
|
try {
|
||||||
|
const config = yaml.load(yamlContent);
|
||||||
|
if (config?.targets && Array.isArray(config.targets)) {
|
||||||
|
targets = config.targets.map(t => {
|
||||||
|
const protocol = t.protocol || 'tcp';
|
||||||
|
const host = t.host || '127.0.0.1';
|
||||||
|
const ports = t.ports || [t.port] || [22];
|
||||||
|
return ports.map(port => `${protocol}:${host}:${port}`);
|
||||||
|
}).flat();
|
||||||
|
delay = config.delay || delay;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
updateStatus(`Ошибка парсинга YAML: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
updateStatus("Нет целей для простукивания");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем gateway из конфигурации или поля
|
||||||
|
const gateway = qsi('#gateway')?.value?.trim() || '';
|
||||||
|
|
||||||
|
const result = await window.api.localKnock({
|
||||||
|
targets,
|
||||||
|
delay,
|
||||||
|
verbose,
|
||||||
|
gateway
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
const summary = result.summary;
|
||||||
|
updateStatus(`Локальное простукивание завершено: ${summary.successful}/${summary.total} успешно`);
|
||||||
|
|
||||||
|
// Логируем детальные результаты в консоль
|
||||||
|
if (verbose) {
|
||||||
|
console.log('Local knock results:', result.results);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorMsg = result?.error || 'Неизвестная ошибка локального простукивания';
|
||||||
|
updateStatus(`Ошибка локального простукивания: ${errorMsg}`);
|
||||||
|
console.error('Local knock failed:', result);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
updateStatus(`Ошибка: ${e?.message || String(e)}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// API простукивание через HTTP
|
||||||
|
const body = {};
|
||||||
|
if (mode === "yaml") {
|
||||||
|
body.config_yaml = qst("#configYAML").value;
|
||||||
|
} else if (mode === "inline") {
|
||||||
|
body.targets = qsi("#targets").value;
|
||||||
|
body.delay = qsi("#delay").value;
|
||||||
|
body.verbose = qsi("#verbose").checked;
|
||||||
|
body.waitConnection = qsi("#waitConnection").checked;
|
||||||
|
body.gateway = qsi("#gateway").value;
|
||||||
|
} else {
|
||||||
|
body.targets = serializeFormTargetsToInline();
|
||||||
|
body.delay = qsi("#delay").value;
|
||||||
|
body.verbose = qsi("#verbose").checked;
|
||||||
|
body.waitConnection = qsi("#waitConnection").checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await fetch(`${apiBase}/knock-actions/execute`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...basicAuthHeader(password),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (result?.ok) {
|
||||||
|
updateStatus("Успешно простучали через API...");
|
||||||
|
} else {
|
||||||
|
updateStatus(`Ошибка API: ${result.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
updateStatus(`Ошибка: ${e?.message || String(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
qs("#encrypt")?.addEventListener("click", async () => {
|
||||||
|
const password = qsi("#password").value;
|
||||||
|
const content = qst("#configYAML").value;
|
||||||
|
const pathFromYaml = extractPathFromYaml(content);
|
||||||
|
if (!content.trim()) return;
|
||||||
|
const url = `${apiBase}/knock-actions/encrypt`;
|
||||||
|
const payload = { yaml: content };
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...basicAuthHeader(password),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
const encrypted = res?.encrypted || "";
|
||||||
|
qst("#configYAML").value = encrypted;
|
||||||
|
updateStatus("Зашифровано");
|
||||||
|
if (!pathFromYaml) {
|
||||||
|
await window.api.saveAs({
|
||||||
|
suggestedName: "config.encrypted",
|
||||||
|
content: encrypted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
updateStatus(`Ошибка: ${e?.message || String(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
qs("#decrypt")?.addEventListener("click", async () => {
|
||||||
|
const password = qsi("#password").value;
|
||||||
|
const content = qst("#configYAML").value;
|
||||||
|
if (!content.trim() || !isEncryptedYaml(content)) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${apiBase}/knock-actions/decrypt`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...basicAuthHeader(password),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ encrypted: content }),
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
const plain = res?.yaml || "";
|
||||||
|
qst("#configYAML").value = plain;
|
||||||
|
const p = extractPathFromYaml(plain);
|
||||||
|
if (p) qsi("#serverFilePath").value = p;
|
||||||
|
updateStatus("Расшифровано");
|
||||||
|
} catch (e) {
|
||||||
|
updateStatus(`Ошибка: ${e?.message || String(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderTargets();
|
||||||
|
|
||||||
|
// Обновляем конфиг при фокусе окна (если настройки были изменены)
|
||||||
|
window.addEventListener('focus', updateConfigFromSettings);
|
||||||
|
|
||||||
|
// Диагностические функции
|
||||||
|
window.testNetworkInterfaces = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.api.getNetworkInterfaces();
|
||||||
|
if (result.success) {
|
||||||
|
console.log('Network interfaces:', result.interfaces);
|
||||||
|
updateStatus('Network interfaces logged to console');
|
||||||
|
} else {
|
||||||
|
updateStatus(`Error getting interfaces: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
updateStatus(`Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.testConnection = async () => {
|
||||||
|
try {
|
||||||
|
const gateway = qsi('#gateway')?.value?.trim();
|
||||||
|
if (!gateway) {
|
||||||
|
updateStatus('Please set gateway first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.api.testConnection({
|
||||||
|
host: '192.168.89.1',
|
||||||
|
port: 2655,
|
||||||
|
localAddress: gateway
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
updateStatus(`Test connection successful: ${result.message}`);
|
||||||
|
console.log('Test connection result:', result);
|
||||||
|
} else {
|
||||||
|
updateStatus(`Test connection failed: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
updateStatus(`Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавляем диагностические кнопки в консоль
|
||||||
|
console.log('Diagnostic functions available:');
|
||||||
|
console.log('- window.testNetworkInterfaces() - Show network interfaces');
|
||||||
|
console.log('- window.testConnection() - Test connection with gateway');
|
||||||
|
});
|
||||||
|
})();
|
||||||
163
desktop/src/renderer/settings.html
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Настройки - Knocker Desktop</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 15px 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
⚙️ Настройки приложения
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="configJson">Конфигурация (JSON формат):</label>
|
||||||
|
<textarea id="configJson" placeholder="Загрузка конфигурации..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help">
|
||||||
|
<strong>Доступные параметры:</strong><br>
|
||||||
|
• <code>apiBase</code> - URL API сервера (например: "http://localhost:8080/api/v1")<br>
|
||||||
|
• <code>gateway</code> - Шлюз по умолчанию<br>
|
||||||
|
• <code>inlineTargets</code> - Inline цели (в формате "tcp:127.0.0.1:22")<br>
|
||||||
|
• <code>delay</code> - Задержка (например: "1s")
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn-secondary" id="cancelBtn">Вернуться</button>
|
||||||
|
<button class="btn-primary" id="saveBtn">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="settings.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
149
desktop/src/renderer/settings.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
(() => {
|
||||||
|
const qs = (sel) => document.querySelector(sel);
|
||||||
|
const qst = (sel) => document.querySelector(sel);
|
||||||
|
|
||||||
|
function showStatus(message, type = 'success') {
|
||||||
|
const status = qs('#status');
|
||||||
|
status.textContent = message;
|
||||||
|
status.className = `status ${type}`;
|
||||||
|
status.style.display = 'block';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
status.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateJson(text) {
|
||||||
|
try {
|
||||||
|
JSON.parse(text);
|
||||||
|
return { valid: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { valid: false, error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJson(obj) {
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка текущей конфигурации
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const config = await window.api.getAllConfig();
|
||||||
|
const jsonText = formatJson(config);
|
||||||
|
qst('#configJson').value = jsonText;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load config:', e);
|
||||||
|
showStatus('Ошибка загрузки конфигурации', 'error');
|
||||||
|
qst('#configJson').value = '{}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение конфигурации
|
||||||
|
async function saveConfig() {
|
||||||
|
const text = qst('#configJson').value.trim();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
showStatus('Конфигурация не может быть пустой', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateJson(text);
|
||||||
|
if (!validation.valid) {
|
||||||
|
showStatus(`Неверный JSON: ${validation.error}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(text);
|
||||||
|
const result = await window.api.setAllConfig(config);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
showStatus('Конфигурация успешно сохранена');
|
||||||
|
|
||||||
|
// Обновляем конфиг в главном окне
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.opener) {
|
||||||
|
window.opener.location.reload();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
showStatus(`Ошибка сохранения: ${result.error}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save config:', e);
|
||||||
|
showStatus('Ошибка сохранения конфигурации', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрытие окна через IPC
|
||||||
|
async function closeWindow() {
|
||||||
|
try {
|
||||||
|
const result = await window.api.closeSettings();
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error('Failed to close settings window:', result.error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error closing settings window:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчики событий
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Загружаем конфигурацию при открытии
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
// Кнопка сохранения
|
||||||
|
qs('#saveBtn').addEventListener('click', saveConfig);
|
||||||
|
|
||||||
|
// Кнопка возврата
|
||||||
|
qs('#cancelBtn').addEventListener('click', () => closeWindow());
|
||||||
|
|
||||||
|
// Горячие клавиши
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveConfig();
|
||||||
|
} else if (e.key === 'w' || e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Автосохранение при изменении (опционально)
|
||||||
|
let saveTimeout;
|
||||||
|
qst('#configJson').addEventListener('input', () => {
|
||||||
|
clearTimeout(saveTimeout);
|
||||||
|
// Можно добавить автосохранение через 5 секунд бездействия
|
||||||
|
// saveTimeout = setTimeout(() => {
|
||||||
|
// const validation = validateJson(qst('#configJson').value);
|
||||||
|
// if (validation.valid) {
|
||||||
|
// saveConfig();
|
||||||
|
// }
|
||||||
|
// }, 5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Предотвращение случайного закрытия с несохраненными изменениями
|
||||||
|
let hasUnsavedChanges = false;
|
||||||
|
qst('#configJson').addEventListener('input', () => {
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Убираем beforeunload для Electron (не работает корректно)
|
||||||
|
// window.addEventListener('beforeunload', (e) => {
|
||||||
|
// if (hasUnsavedChanges) {
|
||||||
|
// e.preventDefault();
|
||||||
|
// e.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?';
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// Сбрасываем флаг после сохранения
|
||||||
|
const originalSaveConfig = saveConfig;
|
||||||
|
saveConfig = async function() {
|
||||||
|
await originalSaveConfig();
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
};
|
||||||
|
})();
|
||||||
96
desktop/src/renderer/styles.css
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
header,
|
||||||
|
footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modes label {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constant-mode-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: 280px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
background: #1f2937;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
margin-left: 12px;
|
||||||
|
color: #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#targetsList .target-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr 120px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#targetsList .remove {
|
||||||
|
background: #7f1d1d;
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
}
|
||||||