adde desktop-angular

This commit is contained in:
2025-10-03 15:05:48 +06:00
parent 2c2725cd19
commit cce64b19e8
54 changed files with 23820 additions and 0 deletions

View 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.

View 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, youll 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
View 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/...`.

View 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

File diff suppressed because it is too large Load Diff

View 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}"
}
}
}

View 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
View 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

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View 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"
}
]
}

View 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",
}

View 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"
}
]
}

View 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`

View 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": []
}
}
}
}
}
}

View 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"

View 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)"
]
}
}
]
}

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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'
})
],
};

View File

@@ -0,0 +1,7 @@
import { Routes } from '@angular/router';
import { RootComponent } from './root.component';
export const routes: Routes = [
{ path: '', component: RootComponent },
{ path: '**', redirectTo: '' },
];

View 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);
}
}
}

View 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);
}
}

View 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;
}
}

View 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`;
}
}
}

View 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' }
]
});
}
}

View 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>

View 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; }
}

View 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');
}
}

View File

@@ -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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1 @@
Port kicker

View File

@@ -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),
};

View 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
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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>

View 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));

View 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"
}
]
}

View 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;
}

View 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"
]
}

View 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
}
}

View 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"
]
}

View 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" };
}
});

View 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>

View 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>

View 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>

View 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)
});