Compare commits

9 Commits

84 changed files with 34114 additions and 1127 deletions

26
.gitignore vendored
View File

@@ -1,8 +1,31 @@
.old .old
**/node_modules
**/dist
**/bin
**/target
**/target/**/*
**/target/**/.*
**/dist/**/*
**/dist/**/.*
**/bin/**/*
**/bin/**/.*
ui/node_modules ui/node_modules
desktop/node_modules
desktop-angular/node_modules
desktop-angular/bin
ui/dist ui/dist
desktop/dist
desktop-angular/dist
desktop/bin
desktop/bin/**/*
desktop/bin/**/.*
desktop/dist/**/*
desktop/dist/**/.*
ui/.angular ui/.angular
ui/.vscode ui/.vscode
rust-knocker/target
rust-knocker/target/**/*
rust-knocker/target/**/.*
back/cmd/public back/cmd/public
back/knocker-serve back/knocker-serve
back/cmd/knocker-serve back/cmd/knocker-serve
@@ -10,4 +33,5 @@ back/cmd/knocker-serve.exe
back/cmd/knocker-serve.exe.sha256 back/cmd/knocker-serve.exe.sha256
back/cmd/knocker-serve.exe.sha256.txt back/cmd/knocker-serve.exe.sha256.txt
back/cmd/knocker-serve.exe.sha256.txt.sha256 back/cmd/knocker-serve.exe.sha256.txt.sha256
back/cmd/knocker-serve.exe.sha256.txt.sha256.txt back/cmd/knocker-serve.exe.sha256.txt.sha256.txt

View File

@@ -43,7 +43,7 @@ back-build: embed-ui back-deps
cd $(BACK_DIR) && go build -o knocker-serve . cd $(BACK_DIR) && go build -o knocker-serve .
run: back-build run: back-build
cd $(BACK_DIR) && GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve serve cd $(BACK_DIR) && GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve -v serve
run-bg: back-build run-bg: back-build
cd $(BACK_DIR) && nohup env GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve serve > /tmp/knocker.log 2>&1 & echo $$! && sleep 1 && tail -n +1 /tmp/knocker.log | sed -n '1,60p' cd $(BACK_DIR) && nohup env GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve serve > /tmp/knocker.log 2>&1 & echo $$! && sleep 1 && tail -n +1 /tmp/knocker.log | sed -n '1,60p'

345
article/embed-gui-guide.md Normal file
View File

@@ -0,0 +1,345 @@
# Встраиваем веб-GUI в консольную утилиту: практический гайд
```metadata
id: 2
title: "Встраиваем веб-GUI в консольную утилиту: практический гайд"
readTime: 15-20 минут
date: 2025-09-10 18:00
author: Direct-Dev (Антон)
level: Средний
tags: #go #angular #spa #embed #static #cli #webui #devops
version: 1.0.2
```
## Содержание
- [Встраиваем веб-GUI в консольную утилиту: практический гайд](#встраиваем-веб-gui-в-консольную-утилиту-практический-гайд)
- [Содержание](#содержание)
- [Введение](#введение)
- [Клонируем, собираем, запускаем](#клонируем-собираем-запускаем)
- [Структура проекта](#структура-проекта)
- [Идея и архитектура](#идея-и-архитектура)
- [Минимальный GUI](#минимальный-gui)
- [Сборка фронтенда](#сборка-фронтенда)
- [Встраивание в Go-сервис](#встраивание-в-go-сервис)
- [API: контракт и примеры](#api-контракт-и-примеры)
- [Запуск и проверка](#запуск-и-проверка)
- [SPA под произвольным префиксом (/ui/simple): base href и deploy-url](#spa-под-произвольным-префиксом-uisimple-base-href-и-deploy-url)
- [FAQ и типичные ошибки](#faq-и-типичные-ошибки)
## Введение
Допустим есть желание к консольной Go утилите прикрутить небольшой веб‑интерфейс, чтобы не гонять команды вручную в терминале или расширить круг пользователей.
Как вариант добавляем api в утилиту, делаем SPA на ангуляре, например, дальше кидаем в проект api скомпилированное spa, cобрали бинарь и отдаем статику прямо из Goбинарника (или из рядом лежащей папки).
В качестве утилиты берем go-knocker - утилиту чтобы постучаться по портам ( такая штука повышающая безопасность серверов и устройств). Проект утилиты можно найти тут: <https://github.com/Direct-Dev-Ru/port-knocker.git>
в данном упрощенном интерфейсе используем только следующие поля и inline режим работы утилиты:
на форме будут следующие поля
- Targets (строка вида `tcp:host:port;udp:host:port...`)
- Delay (например `1s`)
- Флаг Wait connection
- Кнопка Execute
Если нужен более «продвинутый» вариант — всегда можно нарастить поля и логику.
## Клонируем, собираем, запускаем
Источник репозитория: [`https://direct-dev.ru/gitea/GiteaAdmin/knock-gui`](https://direct-dev.ru/gitea/GiteaAdmin/knock-gui)
1 Клонируем репозиторий и переключаемся на ветку for-article:
```bash
# HTTPS клон
git clone https://direct-dev.ru/gitea/GiteaAdmin/knock-gui.git
cd knock-gui
# Переход на ветку с упрощенным UI
git checkout for-article
```
2 переходим в папку фронта, ставим зависимости и собираем UI:
```bash
cd ui
# Установим зависимости проекта (node/npm должны быть установлены)
npm ci
# Собираем продовую сборку
./build-for-embeding.sh ../back/cmd/public
cd ..
# или через make
cd ..
make embed-ui
```
3 Запускаем бэкенд (Go 1.21+):
```bash
# Обязательный пароль для Basic-Auth (GUI и API)
export GO_KNOCKER_SERVE_PASS=changeme
# Порт опционален (по умолчанию 8888)
export GO_KNOCKER_SERVE_PORT=8888
# Вариант A: запустить из исходников
cd back
go run ./
# или Вариант B: собрать бинарник и запустить
go build -o knocker-serve ./
./knocker-serve serve
# ну или так
make run PASS=superpass PORT=8888
```
4 Открываем в браузере:
```text
http://localhost:8888
```
Браузер попросит логин/пароль (BasicAuth). Логин любой (например `knocker`), пароль — тот, что в `GO_KNOCKER_SERVE_PASS`. После этого UI загрузится, и та же авторизация «подхватится» для APIвызовов.
5 Заполняем нужные таргеты: tcp:10.10.10.10:8080;udp:10.10.10.20:8888
6 Нажимаем Execute — и смотрим результат. Ошибка авторизации? Проверьте пароль в переменной окружения и перезагрузите страницу.
## Структура проекта
Чтобы лучше ориентироваться, вот ключевые директории и файлы (сокращенно):
```text
knock-gui/
├── ui/ # Angular SPA (минимальный GUI)
│ ├── src/app/knock/
│ │ ├── knock-page.component.ts
│ │ └── knock-page.component.html
│ ├── build-for-embeding.sh # Скрипт сборки и копирования артефактов в back
│ └── ...
├── back/ # Go backend (встроенная статика + API)
│ ├── cmd/
│ │ ├── serve.go # запуск сервера, basic-auth, CORS
│ │ ├── static_routes.go # раздача встроенной статики (SPA routing)
│ │ └── knock_routes.go # эндпойнты API
│ ├── internal/knocker.go # логика работы
│ └── main.go
└── article/ # материалы статьи
```
## Идея и архитектура
- **Фронтенд (Angular SPA)** — статические файлы (`index.html`, `*.js`, `*.css`).
- **Бэкенд (Go + Gin)** — раздаёт эти файлы и держит REST API. Для статики используем `embed.FS` — удобно распространять единый бинарник.
- **Безопасность** — простой BasicAuth. Переменная `GO_KNOCKER_SERVE_PASS` обязательна: без пароля сервер не стартует.
Под капотом статику обслуживает `setupStaticRoutes`, обратите внимание на SPAмаршрутизацию (фоллбэк на `index.html`):
```1:30:/home/su/projects/articles/embed-gui-article/back/cmd/static_routes.go
// Если файл не найден и это маршрут SPA — показываем index.html
```
## Минимальный GUI
Оставил только то, что реально нужно для «пнул порты». В main полный интерфейс ...
Ключевой шаблон компонента:
```12:60:/home/su/projects/articles/embed-gui-article/ui/src/app/knock/knock-page.component.html
<div class="container">
<p-card header="Port Knocker (Minimal UI)">
<form [formGroup]="form" (ngSubmit)="execute()" class="p-fluid">
<div class="grid">
<div class="col-12">
<label>Targets</label>
<input pInputText type="text" formControlName="targets" placeholder="tcp:host:port;udp:host:port" class="w-full" />
</div>
<div class="col-12 md:col-6">
<label>Delay</label>
<input pInputText type="text" formControlName="delay" placeholder="1s" class="w-full" />
</div>
<div class="col-12 md:col-6 flex align-items-center gap-2">
<p-checkbox formControlName="waitConnection" [binary]="true"></p-checkbox>
<label class="checkbox-label">Wait connection</label>
</div>
<div class="col-12">
<button pButton type="submit" label="Execute" class="w-full" [loading]="executing" [disabled]="executing || form.invalid"></button>
</div>
</div>
</form>
</p-card>
</div>
```
Логика отправки запроса:
```1:40:/home/su/projects/articles/embed-gui-article/ui/src/app/knock/knock-page.component.ts
this.http.post('/api/v1/knock-actions/execute', {
targets: v.targets,
delay: v.delay,
waitConnection: v.waitConnection
}).subscribe(...)
```
> ну да, ну да надо сервис и все такое ...
Так как страницы GUI защищены BasicAuth, браузер после ввода пароля сам будет добавлять заголовок Authorization и к XHRзапросам — отдельно в коде его прокидывать не нужно.
## Сборка фронтенда
Собираем Angularприложение и копируем артефакты туда, откуда Go будет отдавать статику.
Скрипт сборки:
```1:20:/home/su/projects/articles/embed-gui-article/ui/build-for-embeding.sh
#!/bin/bash
# Использование: ./build-for-embeding.sh /abs/path/to/back/cmd/public
npx ng build --configuration production
mkdir -p "$DESTINATION_DIR"
cp -r /home/su/projects/angular/project-front/dist/project-front/browser/* "$DESTINATION_DIR"
```
- После `ng build` файлы лежат в `ui/dist/project-front/browser/`.
- Мы копируем их в `back/cmd/public/`, откуда сервер раздаёт статику (а в релизе — всё упакуется в `embed.FS`).
## Встраивание в Go-сервис
Старт сервера и базовая авторизация — в `serve.go`:
```1:26:/home/su/projects/articles/embed-gui-article/back/cmd/serve.go
//go:embed public/*
var embeddedFS embed.FS
...
pass := os.Getenv("GO_KNOCKER_SERVE_PASS")
if strings.TrimSpace(pass) == "" {
return fmt.Errorf("GO_KNOCKER_SERVE_PASS не задан — задайте пароль для доступа к GUI/API")
}
...
setupStaticRoutes(r, embeddedFS)
```
То есть без пароля сервер не стартует — и это хорошо.
Альтернативно можно отдать файлы с диска, но `embed.FS` удобнее: один бинарник — и поехали.
## API: контракт и примеры
Endpoint: `POST /api/v1/knock-actions/execute`
Request JSON:
```json
{
"targets": "tcp:127.0.0.1:22;udp:1.2.3.4:53",
"delay": "1s",
"waitConnection": false
}
```
Response 200:
```json
{ "status": "ok" }
```
Проверка через curl (не забудьте базовую авторизацию):
```bash
curl -X POST http://localhost:8888/api/v1/knock-actions/execute \
-u knocker:changeme \
-H 'Content-Type: application/json' \
-d '{"targets":"tcp:127.0.0.1:22","delay":"1s","waitConnection":false}'
```
## Запуск и проверка
1 Собрать фронт и скопировать файлы:
```bash
cd ui
./build-for-embeding.sh ../back/cmd/public
cd ..
```
2 Запустить бэкенд:
```bash
export GO_KNOCKER_SERVE_PASS=changeme
export GO_KNOCKER_SERVE_PORT=8888
cd back
go run ./
```
3 Открыть в браузере `/` и проверить, что форма грузится, а `Execute` бьёт в API:
```text
http://localhost:8888
```
Если что-то не так — загляните в логи терминала и сетевую вкладку DevTools.
## SPA под произвольным префиксом (/ui/simple): base href и deploy-url
Иногда нужно отдавать SPA не с корня `/`, а, скажем, по пути `/ui/simple`. Для Angular это значит две вещи: правильный `<base href>` в `index.html` и корректные пути к ассетам.
Вариант A: собрать с заданным base-href и deploy-url:
```bash
# пример сборки под префикс /ui/simple
npx ng build --configuration production \
--base-href /ui/simple/ \
--deploy-url /ui/simple/
```
Что это даёт:
- В `dist/.../browser/index.html` будет `<base href="/ui/simple/">`.
- Все ссылки на бандлы/ассеты будут начинаться с `/ui/simple/`.
Вариант B: поправить `index.html` вручную после сборки (минимальный вариант):
```html
<!-- ui/dist/project-front/browser/index.html -->
<base href="/ui/simple/">
```
Важно: закрывающий слеш обязателен, иначе роутинг может «ехать».
Настройка бэкенда:
- Отгружайте содержимое собранной папки по маршруту `/ui/simple` (или скопируйте артефакты в `back/cmd/public/ui/simple/`).
- SPAфоллбэк должен отдавать `index.html` при запросах внутри префикса, если это не файловые ресурсы.
Если у вас универсальный обработчик (как в `setupStaticRoutes`) на корне, два простых подхода:
- Хранить файлы в `public/ui/simple/...` — тогда запросы к `/ui/simple/...` будут отработаны корректно.
- Сделать отдельный хендлер, который для путей с префиксом `/ui/simple` читает файлы из подкаталога, а на несуществующие файлы отвечает содержимым `public/ui/simple/index.html`.
Пример маппинга структуры в `public/`:
```text
public/
├── ui/
│ └── simple/
│ ├── index.html # с <base href="/ui/simple/">
│ ├── main-*.js
│ ├── styles-*.css
│ └── assets/...
└── ...
```
Если при прямом заходе на вложенный роут `/ui/simple/some/child` видите 404 — значит фоллбэк не отрабатывает. Проверьте, что при отсутствии файла по этому пути сервер возвращает `public/ui/simple/index.html`.
## FAQ и типичные ошибки
- «Сервер ругается на пароль»: не задали `GO_KNOCKER_SERVE_PASS` или запустили в другой сессии. Экспортните переменную и перезапустите.
- «GUI просит логин/пароль, а потом 401 на API»: проверьте правильность пароля, перезагрузите страницу. Браузер должен автоматом подставлять Authorization.
- «Статика не находится»: проверьте, что после сборки файлы UI скопированы в `back/cmd/public/` и сервер перезапущен.
- «SPA роуты 404 при F5»: `static_routes.go` делает фоллбэк на `index.html`. Если меняли пути — не сломайте фоллбек.
- «CORS в разработке»: мы разрешили `http://localhost:4200` и `:8888`. Если запускаете UI отдельно, не забудьте CORS.
---
Если хочется глубже — можно прикрутить версионирование артефактов, CI/CD и полноценный embed всех файлов. Но для старта достаточно описанных шагов — клон, сборка, копирование, запуск. Увидел проблему — открывайте тикет в репозитории: [`https://direct-dev.ru/gitea/GiteaAdmin/knock-gui`](https://direct-dev.ru/gitea/GiteaAdmin/knock-gui)

View File

@@ -0,0 +1,116 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"port-knocker/internal"
)
type Request struct {
Targets []string `json:"targets"`
Delay string `json:"delay"`
Verbose bool `json:"verbose"`
Gateway string `json:"gateway"`
}
type Response struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
func parseTargets(in []string, delayStr string, gateway string) (*internal.Config, error) {
cfg := &internal.Config{Targets: []internal.Target{}}
d := time.Second
if strings.TrimSpace(delayStr) != "" {
dur, err := time.ParseDuration(delayStr)
if err == nil {
d = dur
}
}
for _, t := range in {
t = strings.TrimSpace(t)
if t == "" {
continue
}
parts := strings.Split(t, ":")
if len(parts) < 3 {
return nil, fmt.Errorf("invalid target: %s", t)
}
protocol := strings.ToLower(parts[0])
host := parts[1]
portStr := parts[2]
gw := ""
if len(parts) >= 4 && strings.TrimSpace(parts[3]) != "" {
gw = strings.TrimSpace(parts[3])
} else if strings.TrimSpace(gateway) != "" {
gw = strings.TrimSpace(gateway)
}
// single port
var port int
fmt.Sscanf(portStr, "%d", &port)
cfg.Targets = append(cfg.Targets, internal.Target{
Host: host,
Ports: []int{port},
Protocol: protocol,
Delay: internal.Duration(d),
WaitConnection: false,
Gateway: gw,
})
}
return cfg, nil
}
func readAllStdin() ([]byte, error) {
reader := bufio.NewReader(os.Stdin)
var b []byte
for {
chunk, isPrefix, err := reader.ReadLine()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
b = append(b, chunk...)
if !isPrefix {
b = append(b, '\n')
}
}
return b, nil
}
func main() {
// Read JSON from stdin
data, err := readAllStdin()
if err != nil || len(strings.TrimSpace(string(data))) == 0 {
// fallback to args? not required; expect stdin
}
var req Request
if err := json.Unmarshal(data, &req); err != nil {
_ = json.NewEncoder(os.Stdout).Encode(Response{Success: false, Error: fmt.Sprintf("invalid json: %v", err)})
return
}
cfg, err := parseTargets(req.Targets, req.Delay, req.Gateway)
if err != nil {
_ = json.NewEncoder(os.Stdout).Encode(Response{Success: false, Error: err.Error()})
return
}
pk := internal.NewPortKnocker()
// Всегда без лишних логов на stdout (stdout должен содержать только JSON ответ)
verbose := false
if err := pk.ExecuteWithConfig(cfg, verbose, false); err != nil {
_ = json.NewEncoder(os.Stdout).Encode(Response{Success: false, Error: err.Error()})
return
}
_ = json.NewEncoder(os.Stdout).Encode(Response{Success: true, Message: "ok"})
}

View File

@@ -24,10 +24,13 @@ func setupKnockRoutes(api *gin.RouterGroup) {
ConfigYaml string `json:"config_yaml"` ConfigYaml string `json:"config_yaml"`
} }
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
// fmt.Printf("bad json: %v\n", err)
c.JSON(400, gin.H{"error": fmt.Sprintf("bad json: %v", err)}) c.JSON(400, gin.H{"error": fmt.Sprintf("bad json: %v", err)})
return return
} }
// fmt.Printf("req: %+v\n", req)
knocker := internal.NewPortKnocker() knocker := internal.NewPortKnocker()
// Определяем режим: inline или YAML // Определяем режим: inline или YAML
@@ -39,6 +42,8 @@ func setupKnockRoutes(api *gin.RouterGroup) {
return return
} }
fmt.Printf("config: %+v\n", config)
// Применяем дополнительные параметры из запроса // Применяем дополнительные параметры из запроса
if req.Gateway != "" { if req.Gateway != "" {
for i := range config.Targets { for i := range config.Targets {
@@ -47,7 +52,8 @@ func setupKnockRoutes(api *gin.RouterGroup) {
} }
if err := knocker.ExecuteWithConfig(config, req.Verbose, req.WaitConnection); err != nil { if err := knocker.ExecuteWithConfig(config, req.Verbose, req.WaitConnection); err != nil {
c.JSON(400, gin.H{"error": err.Error()}) fmt.Printf("error: %v\n", err)
c.JSON(400, gin.H{"status": "error","error": err.Error()})
return return
} }
c.JSON(200, gin.H{"status": "ok"}) c.JSON(200, gin.H{"status": "ok"})
@@ -58,7 +64,7 @@ func setupKnockRoutes(api *gin.RouterGroup) {
c.JSON(400, gin.H{"error": "targets is required in inline mode"}) c.JSON(400, gin.H{"error": "targets is required in inline mode"})
return return
} }
config, err := parseInlineTargetsWithWait(req.Targets, req.Delay, req.WaitConnection) config, err := parseInlineTargetsWithWait(req.Targets, req.Delay, req.WaitConnection, req.Gateway)
if err != nil { if err != nil {
c.JSON(400, gin.H{"error": fmt.Sprintf("invalid targets: %v", err)}) c.JSON(400, gin.H{"error": fmt.Sprintf("invalid targets: %v", err)})
return return
@@ -71,7 +77,7 @@ func setupKnockRoutes(api *gin.RouterGroup) {
} }
} }
if err := knocker.ExecuteWithConfig(&config, req.Verbose, req.WaitConnection); err != nil { if err := knocker.ExecuteWithConfig(&config, true || req.Verbose, req.WaitConnection); err != nil {
c.JSON(400, gin.H{"error": err.Error()}) c.JSON(400, gin.H{"error": err.Error()})
return return
} }
@@ -81,7 +87,7 @@ func setupKnockRoutes(api *gin.RouterGroup) {
} }
// parseInlineTargetsWithWait парсит inline строку целей в Config с поддержкой waitConnection // parseInlineTargetsWithWait парсит inline строку целей в Config с поддержкой waitConnection
func parseInlineTargetsWithWait(targets, delay string, waitConnection bool) (internal.Config, error) { func parseInlineTargetsWithWait(targets, delay string, waitConnection bool, gateway string) (internal.Config, error) {
var config internal.Config var config internal.Config
// Парсим targets // Парсим targets
@@ -93,14 +99,18 @@ func parseInlineTargetsWithWait(targets, delay string, waitConnection bool) (int
} }
parts := strings.Split(targetStr, ":") parts := strings.Split(targetStr, ":")
if len(parts) != 3 { if !(len(parts) == 3 || len(parts) == 4) {
return config, fmt.Errorf("invalid target format: %s (expected protocol:host:port)", targetStr) return config, fmt.Errorf("invalid target format: %s (expected protocol:host:port or protocol:host:port:gateway)", targetStr)
} }
protocol := strings.TrimSpace(parts[0]) protocol := strings.TrimSpace(parts[0])
host := strings.TrimSpace(parts[1]) host := strings.TrimSpace(parts[1])
portStr := strings.TrimSpace(parts[2]) portStr := strings.TrimSpace(parts[2])
if len(parts) == 4 {
gateway = strings.TrimSpace(parts[3])
}
if protocol != "tcp" && protocol != "udp" { if protocol != "tcp" && protocol != "udp" {
return config, fmt.Errorf("unsupported protocol: %s (only tcp/udp supported)", protocol) return config, fmt.Errorf("unsupported protocol: %s (only tcp/udp supported)", protocol)
} }
@@ -128,6 +138,7 @@ func parseInlineTargetsWithWait(targets, delay string, waitConnection bool) (int
Ports: []int{port}, Ports: []int{port},
Delay: targetDelay, Delay: targetDelay,
WaitConnection: waitConnection, WaitConnection: waitConnection,
Gateway: gateway,
} }
config.Targets = append(config.Targets, target) config.Targets = append(config.Targets, target)

View File

@@ -1,6 +1,6 @@
package cmd package cmd
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@@ -100,13 +100,17 @@ func parseInlineTargets(targetsStr, delayStr string) (*internal.Config, error) {
// Разбираем формат [proto]:[host]:[port] // Разбираем формат [proto]:[host]:[port]
parts := strings.Split(targetStr, ":") parts := strings.Split(targetStr, ":")
if len(parts) != 3 { if len(parts) != 3 && len(parts) != 4 {
return nil, fmt.Errorf("неверный формат цели '%s', ожидается [proto]:[host]:[port]", targetStr) return nil, fmt.Errorf("неверный формат цели '%s', ожидается [proto]:[host]:[port] или [proto]:[host]:[port]:[gateway]", targetStr)
} }
protocol := strings.TrimSpace(parts[0]) protocol := strings.TrimSpace(parts[0])
host := strings.TrimSpace(parts[1]) host := strings.TrimSpace(parts[1])
portStr := strings.TrimSpace(parts[2]) portStr := strings.TrimSpace(parts[2])
gateway := ""
if len(parts) == 4 {
gateway = strings.TrimSpace(parts[3])
}
// Проверяем протокол // Проверяем протокол
if protocol != "tcp" && protocol != "udp" { if protocol != "tcp" && protocol != "udp" {
@@ -130,7 +134,7 @@ func parseInlineTargets(targetsStr, delayStr string) (*internal.Config, error) {
Protocol: protocol, Protocol: protocol,
Delay: internal.Duration(delay), Delay: internal.Duration(delay),
WaitConnection: false, WaitConnection: false,
Gateway: "", Gateway: gateway,
} }
config.Targets = append(config.Targets, target) config.Targets = append(config.Targets, target)

View File

@@ -43,11 +43,16 @@ func runServe(cmd *cobra.Command, args []string) error {
port = "8888" port = "8888"
} }
host := os.Getenv("GO_KNOCKER_SERVE_HOST")
if strings.TrimSpace(port) == "" {
host = ""
}
r := gin.Default() r := gin.Default()
// CORS: разрешаем для локальной разработки // CORS: разрешаем для локальной разработки
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:4200", "http://127.0.0.1:8888", "http://localhost:8888"}, AllowOrigins: []string{"http://localhost:4200", "http://127.0.0.1:8888", "http://localhost:" + port},
AllowMethods: []string{"GET", "POST", "OPTIONS"}, AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowHeaders: []string{"Authorization", "Content-Type"}, AllowHeaders: []string{"Authorization", "Content-Type"},
AllowCredentials: true, AllowCredentials: true,
@@ -65,6 +70,6 @@ func runServe(cmd *cobra.Command, args []string) error {
setupCryptoRoutes(api, passHash) setupCryptoRoutes(api, passHash)
setupStaticRoutes(r, embeddedFS) setupStaticRoutes(r, embeddedFS)
fmt.Printf("Serving on :%s\n", port) fmt.Printf("Serving on %s:%s\n", host, port)
return r.Run(":" + port) return r.Run(host + ":" + port)
} }

View File

@@ -35,7 +35,7 @@ require (
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect golang.org/x/sys v0.20.0
golang.org/x/text v0.15.0 // indirect golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect google.golang.org/protobuf v1.34.1 // indirect
) )

View File

@@ -10,9 +10,12 @@ import (
"math/rand" "math/rand"
"net" "net"
"os" "os"
"regexp"
"strings" "strings"
"syscall"
"time" "time"
"golang.org/x/sys/unix"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -278,14 +281,18 @@ func (pk *PortKnocker) knockTarget(target Target, verbose bool) error {
} }
// Вычисляем таймаут как половину интервала между пакетами // Вычисляем таймаут как половину интервала между пакетами
timeout := time.Duration(target.Delay) / 2 timeout := max(time.Duration(target.Delay)/2,
if timeout < 100*time.Millisecond { // минимальный таймаут
timeout = 100 * time.Millisecond // минимальный таймаут 100*time.Millisecond)
}
for i, port := range target.Ports { for i, port := range target.Ports {
if verbose { if verbose {
fmt.Printf(" Отправка пакета на %s:%d (%s)\n", target.Host, port, protocol) if target.Gateway != "" {
fmt.Printf(" Отправка пакета на %s:%d (%s) через шлюз %s\n", target.Host, port, protocol, target.Gateway)
} else {
fmt.Printf(" Отправка пакета на %s:%d (%s)\n", target.Host, port, protocol)
}
} }
if err := pk.sendPacket(target.Host, port, protocol, target.WaitConnection, timeout, target.Gateway); err != nil { if err := pk.sendPacket(target.Host, port, protocol, target.WaitConnection, timeout, target.Gateway); err != nil {
@@ -314,7 +321,8 @@ func (pk *PortKnocker) knockTarget(target Target, verbose bool) error {
} }
// sendPacket отправляет один пакет на указанный хост и порт // sendPacket отправляет один пакет на указанный хост и порт
func (pk *PortKnocker) sendPacket(host string, port int, protocol string, waitConnection bool, timeout time.Duration, gateway string) error { // sendPacket_backup — резервная копия прежней реализации
func (pk *PortKnocker) SendPacket_backup(host string, port int, protocol string, waitConnection bool, timeout time.Duration, gateway string) error {
address := net.JoinHostPort(host, fmt.Sprintf("%d", port)) address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
var conn net.Conn var conn net.Conn
@@ -381,8 +389,8 @@ func (pk *PortKnocker) sendPacket(host string, port int, protocol string, waitCo
return nil return nil
} }
// sendPacketWithoutConnection отправляет пакет без установления соединения // sendPacketWithoutConnection_backup — резервная копия прежней реализации
func (pk *PortKnocker) sendPacketWithoutConnection(host string, port int, protocol string, localAddr net.Addr) error { func (pk *PortKnocker) SendPacketWithoutConnection_backup(host string, port int, protocol string, localAddr net.Addr) error {
address := net.JoinHostPort(host, fmt.Sprintf("%d", port)) address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
switch protocol { switch protocol {
@@ -440,6 +448,155 @@ func (pk *PortKnocker) sendPacketWithoutConnection(host string, port int, protoc
return nil return nil
} }
// parseGateway возвращает локальные адреса для TCP/UDP и (опционально) имя интерфейса,
// если в gateway передано, например, "eth1". Также поддерживается IP[:port].
func parseGateway(gateway string) (tcpLocal *net.TCPAddr, udpLocal *net.UDPAddr, ifaceName string, err error) {
gateway = strings.TrimSpace(gateway)
if gateway == "" {
return nil, nil, "", nil
}
// Если это похоже на имя интерфейса (буквы/цифры/дефисы, без точного IP)
isIfaceName := regexp.MustCompile(`^[A-Za-z0-9_-]+$`).MatchString(gateway) && net.ParseIP(gateway) == nil
if isIfaceName {
// Привязка по интерфейсу. LocalAddr оставим пустым — маршрут выберется ядром,
// а SO_BINDTODEVICE закрепит интерфейс.
return nil, nil, gateway, nil
}
// Иначе трактуем как IP[:port]
host := gateway
if !strings.Contains(gateway, ":") {
host = gateway + ":0"
}
tcpLocal, err = net.ResolveTCPAddr("tcp", host)
if err != nil {
return nil, nil, "", fmt.Errorf("не удалось разрешить локальный TCP адрес %s: %w", host, err)
}
udpLocal, err = net.ResolveUDPAddr("udp", host)
if err != nil {
return nil, nil, "", fmt.Errorf("не удалось разрешить локальный UDP адрес %s: %w", host, err)
}
return tcpLocal, udpLocal, "", nil
}
// buildDialer создаёт net.Dialer с опциональной привязкой к LocalAddr и интерфейсу (Linux SO_BINDTODEVICE)
func buildDialer(protocol string, tcpLocal *net.TCPAddr, udpLocal *net.UDPAddr, timeout time.Duration, ifaceName string) *net.Dialer {
d := &net.Dialer{Timeout: timeout}
if protocol == "tcp" && tcpLocal != nil {
d.LocalAddr = tcpLocal
}
if protocol == "udp" && udpLocal != nil {
d.LocalAddr = udpLocal
}
if strings.TrimSpace(ifaceName) != "" {
d.Control = func(network, address string, c syscall.RawConn) error {
var ctrlErr error
err := c.Control(func(fd uintptr) {
// Привязка сокета к интерфейсу по имени
ctrlErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, ifaceName)
})
if err != nil {
return err
}
return ctrlErr
}
}
return d
}
// sendPacket — обновлённая реализация с поддержкой UDPAddr и SO_BINDTODEVICE
func (pk *PortKnocker) sendPacket(host string, port int, protocol string, waitConnection bool, timeout time.Duration, gateway string) error {
address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
tcpLocal, udpLocal, ifaceName, err := parseGateway(gateway)
if err != nil {
return err
}
switch protocol {
case "tcp":
dialer := buildDialer("tcp", tcpLocal, nil, timeout, ifaceName)
conn, err := dialer.Dial("tcp", address)
if err != nil {
if waitConnection {
return fmt.Errorf("не удалось подключиться к %s: %w", address, err)
}
// без ожидания — пробуем best-effort отправку
return pk.sendPacketWithoutConnection(host, port, protocol, dialer.LocalAddr)
}
defer conn.Close()
_, err = conn.Write([]byte{})
if err != nil {
return fmt.Errorf("не удалось отправить пакет: %w", err)
}
return nil
case "udp":
dialer := buildDialer("udp", nil, udpLocal, timeout, ifaceName)
conn, err := dialer.Dial("udp", address)
if err != nil {
if waitConnection {
return fmt.Errorf("не удалось подключиться к %s: %w", address, err)
}
return pk.sendPacketWithoutConnection(host, port, protocol, dialer.LocalAddr)
}
defer conn.Close()
_, err = conn.Write([]byte{})
if err != nil {
return fmt.Errorf("не удалось отправить пакет: %w", err)
}
return nil
default:
return fmt.Errorf("неподдерживаемый протокол: %s", protocol)
}
}
// sendPacketWithoutConnection — обновлённая реализация best-effort без ожидания соединения
func (pk *PortKnocker) sendPacketWithoutConnection(host string, port int, protocol string, localAddr net.Addr) error {
address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
switch protocol {
case "udp":
// Используем Dialer с коротким таймаутом, локальный адрес может быть *net.UDPAddr
var udpLocal *net.UDPAddr
if la, ok := localAddr.(*net.UDPAddr); ok {
udpLocal = la
}
d := &net.Dialer{Timeout: 200 * time.Millisecond}
if udpLocal != nil {
d.LocalAddr = udpLocal
}
conn, err := d.Dial("udp", address)
if err != nil {
return nil
} // best-effort
defer conn.Close()
_, err = conn.Write([]byte{})
if err != nil {
return nil
}
case "tcp":
// Короткий таймаут и игнор ошибок
var tcpLocal *net.TCPAddr
if la, ok := localAddr.(*net.TCPAddr); ok {
tcpLocal = la
}
d := &net.Dialer{Timeout: 100 * time.Millisecond}
if tcpLocal != nil {
d.LocalAddr = tcpLocal
}
conn, err := d.Dial("tcp", address)
if err != nil {
return nil
}
defer conn.Close()
_, _ = conn.Write([]byte{})
}
return nil
}
// showEasterEgg показывает забавный ASCII-арт // showEasterEgg показывает забавный ASCII-арт
func (pk *PortKnocker) showEasterEgg() { func (pk *PortKnocker) showEasterEgg() {
fmt.Println("\n🎯 🎯 🎯 EASTER EGG ACTIVATED! 🎯 🎯 🎯") fmt.Println("\n🎯 🎯 🎯 EASTER EGG ACTIVATED! 🎯 🎯 🎯")

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

990
desktop/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,990 @@
# Руководство по разработке Knocker Desktop
## 🔍 Подробное описание архитектуры
### Архитектура Electron приложения
``` text
┌─────────────────────────────────────────────────────────────┐
│ MAIN PROCESS │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ src/main/main.js │ │
│ │ • Управление жизненным циклом приложения │ │
│ │ • Создание и управление окнами │ │
│ │ • Доступ к Node.js API (fs, dialog, shell) │ │
│ │ • IPC обработчики для файловых операций │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ IPC (Inter-Process Communication)
┌─────────────────────────────────────────────────────────────┐
│ RENDERER PROCESS │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ src/renderer/ │ │
│ │ • HTML/CSS/JS интерфейс │ │
│ │ • Взаимодействие с пользователем │ │
│ │ • HTTP запросы к API │ │
│ │ • Ограниченный доступ к системе │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ contextBridge
┌─────────────────────────────────────────────────────────────┐
│ PRELOAD SCRIPT │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ src/preload/preload.js │ │
│ │ • Безопасный мост между main и renderer │ │
│ │ • Доступ к Node.js API │ │
│ │ • Экспорт API через window.api │ │
│ │ • Изоляция от прямого доступа к Node.js │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Детальное объяснение процессов
#### 1. Main Process (Основной процесс)
**Роль**: Ядро приложения, управляет всей жизнью приложения.
**Возможности**:
- Создание и управление окнами
- Доступ к Node.js API (файловая система, диалоги, системные функции)
- Обработка системных событий (закрытие приложения, фокус окон)
- IPC сервер для связи с renderer процессами
**Код в `src/main/main.js`**:
```javascript
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
// Создание главного окна с настройками безопасности
function createWindow() {
mainWindow = new BrowserWindow({
width: 1100,
height: 800,
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true, // КРИТИЧНО: изолирует контекст
nodeIntegration: false, // КРИТИЧНО: отключает прямой доступ к Node.js
sandbox: false // Позволяет preload работать
}
});
}
// IPC обработчики - "серверная часть" для renderer
ipcMain.handle('file:open', async () => {
// Безопасная работа с файлами через main процесс
const res = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] }]
});
// Возвращаем данные в renderer процесс
return { canceled: res.canceled, filePath: res.filePaths[0], content: fs.readFileSync(res.filePaths[0], 'utf-8') };
});
```
#### 2. Renderer Process (Процесс рендеринга)
**Роль**: Отображение пользовательского интерфейса, взаимодействие с пользователем.
**Ограничения**:
- Нет прямого доступа к Node.js API
- Работает как обычная веб-страница
- Изолирован от файловой системы
- Может делать HTTP запросы
**Код в `src/renderer/renderer.js`**:
```javascript
// Используем безопасный API из preload
window.addEventListener('DOMContentLoaded', () => {
// Обработчики UI событий
document.getElementById('openFile').addEventListener('click', async () => {
// Вызов через contextBridge API
const result = await window.api.openFile();
if (!result.canceled) {
// Обновляем UI с данными файла
document.getElementById('configYAML').value = result.content;
}
});
// HTTP запросы к backend API
document.getElementById('execute').addEventListener('click', async () => {
const response = await fetch('http://localhost:8080/api/v1/knock-actions/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...basicAuthHeader(password) },
body: JSON.stringify(body)
});
});
});
```
#### 3. Preload Script (Preload скрипт)
**Роль**: Безопасный мост между main и renderer процессами.
**Особенности**:
- Выполняется в renderer процессе
- Имеет доступ к Node.js API
- Изолирован от глобального контекста renderer
- Создает безопасный API через `contextBridge`
**Код в `src/preload/preload.js`**:
```javascript
const { contextBridge, ipcRenderer } = require('electron');
// Создаем безопасный API для renderer процесса
contextBridge.exposeInMainWorld('api', {
// Файловые операции
openFile: () => ipcRenderer.invoke('file:open'),
saveAs: (payload) => ipcRenderer.invoke('file:saveAs', payload),
saveToPath: (payload) => ipcRenderer.invoke('file:saveToPath', payload),
revealInFolder: (filePath) => ipcRenderer.invoke('os:revealInFolder', filePath)
});
// Renderer процесс получает доступ к window.api
// Но НЕ имеет прямого доступа к require, fs, dialog и т.д.
```
### IPC (Inter-Process Communication) - Связь между процессами
#### Как работает IPC
``` text
┌─────────────┐ IPC Message ┌─────────────┐
│ Renderer │ ────────────────> │ Main │
│ Process │ │ Process │
│ │ <──────────────── │ │
└─────────────┘ IPC Response └─────────────┘
```
**Шаг 1**: Renderer процесс вызывает `window.api.openFile()`
**Шаг 2**: Preload скрипт отправляет IPC сообщение `'file:open'` в main процесс
**Шаг 3**: Main процесс обрабатывает сообщение и выполняет файловую операцию
**Шаг 4**: Main процесс возвращает результат через IPC
**Шаг 5**: Preload скрипт получает результат и возвращает его renderer процессу
#### Типы IPC сообщений в приложении
```javascript
// Main процесс (обработчики)
ipcMain.handle('file:open', handler); // Открытие файла
ipcMain.handle('file:saveAs', handler); // Сохранение файла
ipcMain.handle('file:saveToPath', handler); // Сохранение по пути
ipcMain.handle('os:revealInFolder', handler); // Показать в проводнике
// Preload скрипт (клиент)
ipcRenderer.invoke('file:open'); // Отправка запроса
ipcRenderer.invoke('file:saveAs', payload); // Отправка с данными
```
### Безопасность в Electron
#### Принципы безопасности
1. **Context Isolation** - изоляция контекста
```javascript
webPreferences: {
contextIsolation: true // Renderer не может получить доступ к Node.js
}
```
2. **Node Integration** - отключение интеграции Node.js
```javascript
webPreferences: {
nodeIntegration: false // Отключаем прямой доступ к require()
}
```
3. **Sandbox** - песочница
```javascript
webPreferences: {
sandbox: false // Позволяем preload работать
}
```
#### Почему такая архитектура?
**Проблема**: Renderer процесс работает с ненадежным контентом (HTML/JS от пользователя).
**Решение**: Изолируем renderer от Node.js API, но предоставляем безопасный доступ через preload.
```javascript
// ❌ НЕБЕЗОПАСНО (если включить nodeIntegration: true)
// В renderer процессе:
const fs = require('fs');
fs.readFileSync('/etc/passwd'); // Может прочитать системные файлы!
// ✅ БЕЗОПАСНО (через contextBridge)
// В renderer процессе:
const result = await window.api.openFile(); // Только разрешенные операции
```
## 🎯 Функциональность приложения
### Режимы работы
#### 1. Inline режим
```javascript
// Простые поля для быстрого ввода
const formData = {
password: 'user_password',
targets: 'tcp:127.0.0.1:22;tcp:192.168.1.1:80',
delay: '1s',
verbose: true,
waitConnection: false,
gateway: 'optional_gateway'
};
```
#### 2. YAML режим
```yaml
# Полная YAML конфигурация
targets:
- protocol: tcp
host: 127.0.0.1
ports: [22, 80]
wait_connection: true
- protocol: udp
host: 192.168.1.1
ports: [53]
delay: 1s
path: /etc/knocker/config.yaml # Путь на сервере
```
#### 3. Form режим
```javascript
// Табличная форма для добавления целей
const targets = [
{ protocol: 'tcp', host: '127.0.0.1', port: 22, gateway: '' },
{ protocol: 'udp', host: '192.168.1.1', port: 53, gateway: 'gw1' }
];
```
### Файловые операции
#### Открытие файлов
```javascript
// Main процесс
ipcMain.handle('file:open', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] }]
});
if (result.canceled) return { canceled: true };
const filePath = result.filePaths[0];
const content = fs.readFileSync(filePath, 'utf-8');
return { canceled: false, filePath, content };
});
```
#### Сохранение файлов
```javascript
// Main процесс
ipcMain.handle('file:saveAs', async (event, payload) => {
const result = await dialog.showSaveDialog({
defaultPath: payload.suggestedName || 'config.yaml',
filters: [{ name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] }]
});
if (result.canceled || !result.filePath) return { canceled: true };
fs.writeFileSync(result.filePath, payload.content, 'utf-8');
return { canceled: false, filePath: result.filePath };
});
```
### HTTP API интеграция
#### Basic Authentication
```javascript
function basicAuthHeader(password) {
const token = btoa(`knocker:${password}`);
return { Authorization: `Basic ${token}` };
}
// Использование в запросах
const response = await fetch('http://localhost:8080/api/v1/knock-actions/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...basicAuthHeader(password)
},
body: JSON.stringify(payload)
});
```
#### API endpoints
```javascript
const apiEndpoints = {
execute: '/api/v1/knock-actions/execute',
encrypt: '/api/v1/knock-actions/encrypt',
decrypt: '/api/v1/knock-actions/decrypt',
encryptFile: '/api/v1/knock-actions/encrypt-file'
};
```
### YAML обработка
#### Извлечение пути из YAML
```javascript
function extractPathFromYaml(text) {
try {
const doc = yaml.load(text);
if (doc && typeof doc === 'object' && typeof doc.path === 'string') {
return doc.path;
}
} catch (e) {
console.warn('Failed to parse YAML:', e);
}
return '';
}
```
#### Обновление пути в YAML
```javascript
function patchYamlPath(text, newPath) {
try {
const doc = text.trim() ? yaml.load(text) : {};
if (doc && typeof doc === 'object') {
doc.path = newPath || '';
return yaml.dump(doc, { lineWidth: 120 });
}
} catch (e) {
console.warn('Failed to update YAML path:', e);
}
return text;
}
```
#### Конвертация между режимами
```javascript
// Inline → YAML
function convertInlineToYaml(targetsStr, delay, waitConnection) {
const entries = targetsStr.split(';').filter(Boolean);
const config = {
targets: entries.map(entry => {
const [protocol, host, port] = entry.split(':');
return {
protocol: protocol || 'tcp',
host: host || '127.0.0.1',
ports: [parseInt(port) || 22],
wait_connection: waitConnection
};
}),
delay: delay || '1s'
};
return yaml.dump(config, { lineWidth: 120 });
}
// YAML → Inline
function convertYamlToInline(yamlText) {
const config = yaml.load(yamlText) || {};
const targets = [];
(config.targets || []).forEach(target => {
const protocol = target.protocol || 'tcp';
const host = target.host || '127.0.0.1';
const ports = target.ports || [target.port] || [22];
ports.forEach(port => {
targets.push(`${protocol}:${host}:${port}`);
});
});
return {
targets: targets.join(';'),
delay: config.delay || '1s',
waitConnection: !!(config.targets?.[0]?.wait_connection)
};
}
```
## 🔧 Разработка и отладка
### Настройка среды разработки
#### 1. Структура проекта
``` text
desktop/
├── src/
│ ├── main/
│ │ ├── main.js # Основной процесс (CommonJS)
│ │ └── main.ts # TypeScript версия (опционально)
│ ├── preload/
│ │ ├── preload.js # Preload скрипт (CommonJS)
│ │ └── preload.ts # TypeScript версия (опционально)
│ └── renderer/
│ ├── index.html # HTML разметка
│ ├── styles.css # Стили
│ ├── renderer.js # UI логика (ванильный JS)
│ └── renderer.ts # TypeScript версия (опционально)
├── assets/ # Иконки для сборки
├── dist/ # Собранные приложения
├── package.json # Конфигурация
├── README.md # Основная документация
└── DEVELOPMENT.md # Это руководство
```
#### 2. Зависимости
```json
{
"devDependencies": {
"electron": "^28.3.3", // Electron runtime
"electron-builder": "^26.0.12" // Сборка и пакетирование
},
"dependencies": {
"axios": "^1.12.2", // HTTP клиент (не используется в финальной версии)
"js-yaml": "^4.1.0" // YAML парсер
}
}
```
### Отладка
#### DevTools
```javascript
// В main.js автоматически открываются DevTools
mainWindow.webContents.openDevTools();
```
#### Логирование
```javascript
// Main процесс - логи в терминале
console.log('Main process:', data);
// Renderer процесс - логи в DevTools Console
console.log('Renderer process:', data);
// IPC отладка в preload
const originalInvoke = ipcRenderer.invoke;
ipcRenderer.invoke = function(channel, ...args) {
console.log(`IPC Request: ${channel}`, args);
return originalInvoke.call(this, channel, ...args).then(result => {
console.log(`IPC Response: ${channel}`, result);
return result;
});
};
```
#### Отладка файловых операций
```javascript
// В main.js добавить логирование
ipcMain.handle('file:open', async () => {
console.log('Opening file dialog...');
const result = await dialog.showOpenDialog({...});
console.log('Dialog result:', result);
// ...
});
```
### Тестирование
#### Локальное тестирование
```bash
# Запуск в режиме разработки
npm run dev
# Проверка функциональности:
# 1. Открытие файлов
# 2. Сохранение файлов
# 3. HTTP запросы к API
# 4. Переключение между режимами
# 5. Конвертация YAML ↔ Inline
```
#### Тестирование сборки
```bash
# Упаковка без установщика
npm run pack
# Полная сборка
npm run build
# Проверка на разных платформах
npm run build:win
npm run build:linux
npm run build:mac
```
## 📦 Сборка и распространение
### Electron Builder конфигурация
```json
{
"build": {
"appId": "com.knocker.desktop", // Уникальный ID приложения
"productName": "Knocker Desktop", // Имя продукта
"directories": {
"output": "dist" // Папка для сборки
},
"files": [
"src/**/*", // Исходный код
"node_modules/**/*" // Зависимости
],
"win": {
"target": "nsis", // Windows installer
"icon": "assets/icon.ico" // Иконка Windows
},
"linux": {
"target": "AppImage", // Linux portable app
"icon": "assets/icon.png" // Иконка Linux
},
"mac": {
"target": "dmg", // macOS disk image
"icon": "assets/icon.icns" // Иконка macOS
}
}
}
```
### Типы сборки
#### Windows
- **NSIS** - установщик с мастером установки
- **Portable** - портативная версия
- **Squirrel** - автообновления
#### Linux
- **AppImage** - портативное приложение
- **deb** - пакет для Debian/Ubuntu
- **rpm** - пакет для Red Hat/Fedora
- **tar.xz** - архив
#### macOS
- **dmg** - образ диска
- **pkg** - установщик пакета
- **mas** - Mac App Store
### Команды сборки
```bash
# Сборка для текущей платформы
npm run build
# Сборка для конкретных платформ
npm run build:win # Windows (NSIS)
npm run build:linux # Linux (AppImage)
npm run build:mac # macOS (DMG)
# Упаковка без установщика (для тестирования)
npm run pack
# Сборка без публикации
npm run dist
# Публикация (если настроено)
npm run publish
```
### Иконки и ресурсы
#### Требования к иконкам
``` text
assets/
├── icon.ico # Windows: 256x256, ICO формат
├── icon.png # Linux: 512x512, PNG формат
└── icon.icns # macOS: 512x512, ICNS формат
```
#### Создание иконок
```bash
# Из PNG в ICO (Windows)
convert icon.png -resize 256x256 icon.ico
# Из PNG в ICNS (macOS)
iconutil -c icns icon.iconset
```
### Автоматизация сборки
#### GitHub Actions пример
```yaml
name: Build Electron App
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Upload artifacts
uses: actions/upload-artifact@v2
with:
name: ${{ matrix.os }}
path: dist/
```
## 🚀 Производительность и оптимизация
### Оптимизация размера приложения
#### Исключение ненужных файлов
```json
{
"build": {
"files": [
"src/**/*",
"node_modules/**/*"
],
"asarUnpack": [
"node_modules/electron/**/*" // Исключаем Electron из asar
]
}
}
```
#### Tree shaking
```javascript
// Используем только нужные части библиотек
import { load, dump } from 'js-yaml'; // Вместо import * as yaml
```
### Оптимизация загрузки
#### Lazy loading
```javascript
// Загружаем YAML парсер только когда нужен
async function loadYamlParser() {
if (!window.jsyaml) {
await import('../../node_modules/js-yaml/dist/js-yaml.min.js');
}
}
```
#### Кэширование
```javascript
// Кэшируем результаты API запросов
const cache = new Map();
async function cachedApiCall(endpoint, data) {
const key = `${endpoint}:${JSON.stringify(data)}`;
if (cache.has(key)) {
return cache.get(key);
}
const result = await apiCall(endpoint, data);
cache.set(key, result);
return result;
}
```
## 🔒 Безопасность
### Принципы безопасности Electron
#### 1. Context Isolation
```javascript
webPreferences: {
contextIsolation: true // Изолирует контекст renderer от Node.js
}
```
#### 2. Node Integration
```javascript
webPreferences: {
nodeIntegration: false // Отключает прямой доступ к require()
}
```
#### 3. Sandbox
```javascript
webPreferences: {
sandbox: false // Позволяет preload работать (но только в preload)
}
```
#### 4. CSP (Content Security Policy)
```html
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline';">
```
### Валидация входных данных
#### Проверка паролей
```javascript
function validatePassword(password) {
if (!password || password.length < 1) {
throw new Error('Пароль не может быть пустым');
}
return password;
}
```
#### Проверка файлов
```javascript
function validateFileContent(content) {
if (typeof content !== 'string') {
throw new Error('Неверный формат файла');
}
if (content.length > 10 * 1024 * 1024) { // 10MB лимит
throw new Error('Файл слишком большой');
}
return content;
}
```
#### Проверка API ответов
```javascript
async function safeApiCall(url, options) {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}
```
## 🐛 Устранение неполадок
### Частые проблемы и решения
#### 1. Приложение не запускается
```bash
# Проверка зависимостей
npm install
# Очистка и переустановка
rm -rf node_modules package-lock.json
npm install
# Проверка версии Node.js
node --version # Должна быть >= 16
```
#### 2. DevTools не открываются
```javascript
// Убедитесь что в main.js есть:
mainWindow.webContents.openDevTools();
// Или добавьте горячую клавишу:
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.control && input.shift && input.key.toLowerCase() === 'i') {
mainWindow.webContents.openDevTools();
}
});
```
#### 3. Файлы не открываются
```javascript
// Проверьте что backend запущен
const testConnection = async () => {
try {
const response = await fetch('http://localhost:8080/api/v1/health');
console.log('Backend is running');
} catch (error) {
console.error('Backend is not running:', error);
}
};
```
#### 4. Сборка не работает
```bash
# Очистка dist папки
rm -rf dist
# Проверка конфигурации
npm run build -- --debug
# Сборка с подробными логами
DEBUG=electron-builder npm run build
```
#### 5. IPC сообщения не работают
```javascript
// Проверьте что preload скрипт загружается
console.log('Preload loaded:', typeof window.api);
// Проверьте IPC каналы
ipcRenderer.invoke('test').then(result => {
console.log('IPC test result:', result);
});
```
### Отладка производительности
#### Профилирование
```javascript
// В main.js
const { performance } = require('perf_hooks');
const startTime = performance.now();
// ... код ...
const endTime = performance.now();
console.log(`Operation took ${endTime - startTime} milliseconds`);
```
#### Мониторинг памяти
```javascript
// В main.js
setInterval(() => {
const usage = process.memoryUsage();
console.log('Memory usage:', {
rss: Math.round(usage.rss / 1024 / 1024) + ' MB',
heapTotal: Math.round(usage.heapTotal / 1024 / 1024) + ' MB',
heapUsed: Math.round(usage.heapUsed / 1024 / 1024) + ' MB'
});
}, 5000);
```
### Логирование и мониторинг
#### Структурированное логирование
```javascript
// В main.js
const log = {
info: (message, data) => console.log(`[INFO] ${message}`, data),
error: (message, error) => console.error(`[ERROR] ${message}`, error),
debug: (message, data) => console.debug(`[DEBUG] ${message}`, data)
};
// Использование
log.info('Application started');
log.error('File operation failed', error);
```
#### Отслеживание ошибок
```javascript
// Глобальный обработчик ошибок
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
// Можно отправить в сервис мониторинга
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
```
## 📚 Дополнительные ресурсы
### Документация
- [Electron Documentation](https://www.electronjs.org/docs)
- [Electron Builder](https://www.electron.build/)
- [Context Isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation)
- [IPC Communication](https://www.electronjs.org/docs/latest/tutorial/ipc)
### Лучшие практики
- [Electron Security](https://www.electronjs.org/docs/latest/tutorial/security)
- [Performance Best Practices](https://www.electronjs.org/docs/latest/tutorial/performance)
- [Distribution Guide](https://www.electronjs.org/docs/latest/tutorial/distribution)
### Инструменты разработки
- [Electron DevTools](https://www.electronjs.org/docs/latest/tutorial/devtools)
- [VS Code Electron Extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-electron)
- [Electron Fiddle](https://www.electronjs.org/fiddle)
## 🤝 Вклад в разработку
### Процесс разработки
1. Форкните репозиторий
2. Создайте ветку для новой функции: `git checkout -b feature/new-feature`
3. Внесите изменения с тестами
4. Проверьте на всех платформах: `npm run build:win && npm run build:linux && npm run build:mac`
5. Создайте Pull Request с описанием изменений
### Стандарты кода
- Используйте ESLint для проверки JavaScript
- Комментируйте сложную логику
- Следуйте принципам безопасности Electron
- Тестируйте на всех поддерживаемых платформах
### Тестирование (another)
```bash
# Полный цикл тестирования
npm run dev # Тест в режиме разработки
npm run pack # Тест упакованной версии
npm run build # Тест финальной сборки
npm run build:win # Тест Windows версии
npm run build:linux # Тест Linux версии
npm run build:mac # Тест macOS версии
```
Это руководство покрывает все аспекты разработки Electron приложения Knocker Desktop. Используйте его как справочник при работе с проектом.

View File

@@ -0,0 +1,186 @@
# Объяснение работы Gateway и localAddress
## Проблема
При использовании VPN (например, WireGuard) весь интернет-трафик направляется через туннель. Однако для порт-простукивания может потребоваться использовать локальный интерфейс для обхода VPN.
## Решение: localAddress
### Как работает localAddress
`localAddress` - это параметр в Node.js Socket API, который позволяет указать локальный IP-адрес для исходящих соединений. Это заставляет операционную систему использовать конкретный сетевой интерфейс вместо маршрута по умолчанию.
### TCP соединения
```javascript
const socket = new net.Socket();
// Обычное соединение (через маршрут по умолчанию, может идти через VPN)
socket.connect(80, 'example.com');
// Соединение через конкретный локальный IP (обходит VPN)
socket.connect({
port: 80,
host: 'example.com',
localAddress: '192.168.89.1' // Ваш локальный шлюз
});
```
**Важно**: TCP сокеты НЕ поддерживают `socket.bind()`. Используйте `localAddress` в `socket.connect()`.
### UDP пакеты
```javascript
const socket = dgram.createSocket('udp4');
// Привязка к конкретному локальному IP (работает для UDP)
socket.bind(0, '192.168.89.1');
// Отправка пакета через этот интерфейс
socket.send(message, 0, message.length, 53, '8.8.8.8');
```
**Важно**: UDP сокеты поддерживают `socket.bind()` для привязки к локальному IP.
## Ваш случай с WireGuard
### Текущая ситуация:
- WireGuard активен
- Весь трафик идет через туннель
- Нужно простучать порт через локальный шлюз `192.168.89.1`
### Решение:
```javascript
// В настройках приложения указать:
{
"apiBase": "internal",
"gateway": "192.168.89.1"
}
// Или в строке цели:
"tcp:example.com:22:192.168.89.1"
```
### Что происходит:
1. Приложение получает gateway `192.168.89.1`
2. Создается сокет с `localAddress: '192.168.89.1'`
3. Операционная система направляет трафик через интерфейс с IP `192.168.89.1`
4. Трафик обходит WireGuard туннель
## Технические детали
### TCP (socket.connect с localAddress)
```javascript
socket.connect({
port: 22,
host: '192.168.1.100',
localAddress: '192.168.89.1' // Принудительно использует этот локальный IP
});
```
### UDP (socket.bind с localAddress)
```javascript
socket.bind(0, '192.168.89.1'); // Привязка к локальному IP
socket.send(message, port, host); // Отправка через этот интерфейс
```
## Проверка работы
### Логи в консоли
```
Using localAddress 192.168.89.1 to bypass VPN/tunnel
TCP connection to 192.168.1.100:22 via 192.168.89.1 successful
```
### Сетевая диагностика
```bash
# Проверить маршруты
ip route show
# Проверить интерфейсы
ip addr show
# Мониторинг трафика
tcpdump -i any host 192.168.1.100
```
## Возможные проблемы
### 1. "EADDRNOTAVAIL" ошибка
**Причина**: IP-адрес не существует на локальной машине
**Решение**: Указать корректный IP локального интерфейса
### 2. "ENETUNREACH" ошибка
**Причина**: Нет маршрута к цели через указанный интерфейс
**Решение**: Проверить сетевую конфигурацию
### 3. Трафик все еще идет через VPN
**Причина**: Неправильно указан localAddress
**Решение**:
```bash
# Найти локальный IP шлюза
ip route | grep default
# Использовать этот IP как gateway
```
## Примеры конфигурации
### Конфигурация для обхода WireGuard
```json
{
"apiBase": "internal",
"gateway": "192.168.89.1",
"inlineTargets": "tcp:external-server.com:22",
"delay": "1s"
}
```
### Смешанное использование
```
tcp:127.0.0.1:22;tcp:external-server.com:22:192.168.89.1;udp:local-dns.com:53
```
- Первая цель: через VPN (системный маршрут)
- Вторая цель: через локальный шлюз (обход VPN)
- Третья цель: через VPN (системный маршрут)
## Отладка
### Включить подробные логи
```javascript
// В настройках установить verbose: true
{
"verbose": true
}
```
### Проверить в консоли main процесса
```
Knocking TCP external-server.com:22 via 192.168.89.1
Using localAddress 192.168.89.1 to bypass VPN/tunnel
TCP connection to external-server.com:22 via 192.168.89.1 successful
```
### Мониторинг сети
```bash
# Просмотр активных соединений
netstat -an | grep 192.168.89.1
# Мониторинг трафика
sudo tcpdump -i any -n host external-server.com
```
## Безопасность
### Ограничения
- `localAddress` работает только с IP-адресами, существующими на локальной машине
- Необходимы соответствующие права для привязки к сетевым интерфейсам
- Подчиняется правилам файрвола операционной системы
### Рекомендации
- Использовать только доверенные IP-адреса
- Проверять сетевую конфигурацию перед использованием
- Логировать все попытки обхода VPN для аудита
---
**Важно**: `localAddress` - это мощный инструмент для управления сетевым трафиком, но он должен использоваться осторожно, так как может обходить сетевые политики безопасности.

411
desktop/LOCAL_KNOCKING.md Normal file
View File

@@ -0,0 +1,411 @@
# Локальное простукивание портов (Local Port Knocking)
## Обзор
Функционал локального простукивания позволяет выполнять knock операции напрямую через Node.js API без использования внешнего HTTP API сервера. Это обеспечивает независимость от внешних сервисов и возможность работы в автономном режиме.
## Условия активации
Локальное простукивание активируется автоматически когда:
1. **API URL пуст** - поле `apiBase` не заполнено или содержит пустую строку
2. **API URL = "internal"** - значение `apiBase` установлено в `"internal"`
3. **API URL не задан** - значение `apiBase` равно `null` или `undefined`
## Архитектура
### Файлы реализации
#### 1. `src/main/main.js` - Основная логика
**Строки 210-367**: Реализация локального простукивания
**Ключевые функции:**
- `parseTarget(targetStr)` - парсинг строки цели в объект
- `parseDelay(delayStr)` - конвертация задержки в миллисекунды
- `knockTcp(host, port, timeout)` - TCP простукивание
- `knockUdp(host, port, timeout)` - UDP простукивание
- `performLocalKnock(targets, delay, verbose)` - основная функция простукивания
- `ipcMain.handle('knock:local', ...)` - IPC обработчик
**Поддерживаемые протоколы:**
- **TCP** - создает соединение и немедленно закрывает
- **UDP** - отправляет пакет данных (fire-and-forget)
**Формат целей:**
``` text
protocol:host:port[:gateway]
```
Примеры:
- `tcp:127.0.0.1:22`
- `udp:192.168.1.1:53`
- `tcp:example.com:80:gateway.com`
**Поддержка Gateway:**
Gateway можно указать двумя способами:
1. **В строке цели**: `tcp:host:port:gateway_ip`
2. **Глобально через поле Gateway**: используется для всех целей, если не указан в самой цели
**Приоритет gateway:**
- Gateway из строки цели имеет приоритет над глобальным
- Если gateway не указан, используется системный маршрут по умолчанию
**Обход VPN/туннелей:**
Gateway использует `localAddress` для принудительного направления трафика через указанный локальный IP-адрес. Это позволяет:
- Обходить VPN соединения (WireGuard, OpenVPN и др.)
- Использовать конкретный сетевой интерфейс
- Направлять трафик через локальный шлюз
**Пример обхода WireGuard:**
```json
{
"gateway": "192.168.89.1"
}
```
Трафик будет направлен через интерфейс с IP `192.168.89.1`, минуя WireGuard туннель.
## Хелпер для gateway (Rust приоритетно, Go как fallback)
Когда задан `gateway` (IP или имя интерфейса), десктоп-приложение запускает встроенный бинарь из `desktop/bin/`:
- `knock-local-rust` — приоритетный Rust-хелпер (если присутствует)
- `knock-local` — Go-хелпер как запасной вариант
Оба на Linux используют `SO_BINDTODEVICE` для привязки к интерфейсу и надежного обхода VPN/туннелей (WireGuard и пр.).
Сборка при разработке:
- `npm run rust:build` — соберёт Rust-хелпер
- `npm run go:build` — соберёт Go-хелпер
В прод-сборках оба бинаря автоматически включаются в образ приложения.
Важно для TCP: привязка интерфейса устанавливается до `connect()`. Это гарантирует, что исходящее соединение пойдёт через нужный интерфейс, а не в туннель.
**Формат задержки:**
- `1s` - 1 секунда
- `500ms` - 500 миллисекунд (не поддерживается, используйте `0.5s`)
- `2m` - 2 минуты
- `1h` - 1 час
#### 2. `src/preload/preload.js` - IPC мост
**Строка 13**: Добавлен метод `localKnock`
```javascript
localKnock: async (payload) => ipcRenderer.invoke('knock:local', payload)
```
#### 3. `src/renderer/renderer.js` - UI логика
**Строки 317-376**: Логика выбора между локальным и API простукиванием
**Ключевые изменения:**
- Проверка условия `useLocalKnock = !apiBase || apiBase.trim() === '' || apiBase === 'internal'`
- Извлечение targets из всех режимов (inline, form, yaml)
- Вызов `window.api.localKnock()` вместо HTTP запросов
## Режимы работы
### 1. Inline режим
```javascript
// Извлекает targets из поля #targets
targets = qsi("#targets").value.split(';').filter(t => t.trim());
```
### 2. Form режим
```javascript
// Сериализует формы в строку targets
targets = [serializeFormTargetsToInline()];
```
### 3. YAML режим
```javascript
// Парсит YAML и извлекает targets
const config = yaml.load(yamlContent);
targets = config.targets.map(t => {
const protocol = t.protocol || 'tcp';
const host = t.host || '127.0.0.1';
const ports = t.ports || [t.port] || [22];
return ports.map(port => `${protocol}:${host}:${port}`);
}).flat();
```
## API локального простукивания
### Входные параметры
```javascript
{
targets: string[], // Массив целей в формате "protocol:host:port[:gateway]"
delay: string, // Задержка между целями (например "1s")
verbose: boolean, // Подробный вывод в консоль
gateway: string // Глобальный gateway для всех целей (опционально)
}
```
### Выходные данные
```javascript
{
success: boolean, // Успешность операции
results: [ // Детальные результаты по каждой цели
{
target: string, // Исходная строка цели
success: boolean, // Успешность простукивания
message: string // Сообщение о результате
}
],
summary: { // Общая статистика
total: number, // Общее количество целей
successful: number, // Количество успешных
failed: number // Количество неудачных
}
}
```
## Примеры использования
### Настройка для локального режима
#### Вариант 1: Пустой API URL
```json
{
"apiBase": ""
}
```
#### Вариант 2: Специальное значение
```json
{
"apiBase": "internal"
}
```
### Пример конфигурации
```json
{
"apiBase": "internal",
"gateway": "192.168.1.1",
"inlineTargets": "tcp:127.0.0.1:22;tcp:192.168.1.100:80",
"delay": "2s"
}
```
### Пример YAML конфигурации
```yaml
targets:
- protocol: tcp
host: 127.0.0.1
ports: [22, 80]
- protocol: udp
host: 192.168.1.1
ports: [53]
delay: 1s
```
## Логирование и отладка
### Консольный вывод
При `verbose: true` в консоли main процесса появляются сообщения:
``` text
Knocking TCP 127.0.0.1:22
Knocking UDP 192.168.1.1:53 via 192.168.1.1
Knocking TCP example.com:80 via 10.0.0.1
```
### Результаты в DevTools
Детальные результаты логируются в консоль renderer процесса:
```javascript
console.log('Local knock results:', result.results);
```
### Статус в UI
В интерфейсе отображается краткий статус:
``` text
"Локальное простукивание завершено: 2/3 успешно"
```
## Ограничения
### Поддерживаемые протоколы
- ✅ **TCP** - полная поддержка
- ✅ **UDP** - отправка пакетов
- ❌ **ICMP** - не поддерживается
- ❌ **Другие протоколы** - не поддерживаются
### Таймауты
- **TCP**: 5 секунд по умолчанию
- **UDP**: 5 секунд по умолчанию
- Настраивается в коде функций `knockTcp` и `knockUdp`
### Сетевая безопасность
- Локальное простукивание использует системные сокеты
- Подчиняется правилам файрвола операционной системы
- Не требует дополнительных разрешений в Electron
## Совместимость
### Операционные системы
- ✅ **Windows** - полная поддержка
- ✅ **macOS** - полная поддержка
- ✅ **Linux** - полная поддержка
### Electron версии
- ✅ **v28+** - протестировано
- ⚠️ **v27 и ниже** - может потребовать адаптации
## Переключение между режимами
### API → Локальный
1. Открыть настройки (Ctrl/Cmd+,)
2. Установить `apiBase` в `"internal"`
3. Сохранить настройки
4. Перезапустить приложение
### Локальный → API
1. Открыть настройки
2. Установить корректный `apiBase` URL
3. Сохранить настройки
4. Перезапустить приложение
## Устранение неполадок
### Проблема: "No targets provided"
**Причина**: Не удалось извлечь цели из конфигурации
**Решение**: Проверить корректность заполнения полей targets
### Проблема: "Unsupported protocol"
**Причина**: Использован неподдерживаемый протокол
**Решение**: Использовать только `tcp` или `udp`
### Проблема: "Connection timeout"
**Причина**: Цель недоступна или заблокирована файрволом
**Решение**: Проверить доступность цели и настройки файрвола
### Проблема: "Invalid target format"
**Причина**: Неверный формат строки цели
**Решение**: Использовать формат `protocol:host:port`
### Проблема: "Uncaught Exception"
**Причина**: Необработанные ошибки в асинхронных операциях
**Решение**: ✅ **ИСПРАВЛЕНО** - Добавлены глобальные обработчики ошибок и защита от двойного resolve
**Исправления в версии 1.1:**
- Добавлен флаг `resolved` в TCP/UDP функциях для предотвращения двойного вызова resolve
- Глобальные обработчики `uncaughtException` и `unhandledRejection` в main процессе
- Глобальные обработчики ошибок в renderer процессе
- Улучшенная валидация входных данных в IPC обработчике
- Try-catch блоки вокруг всех критических операций
## Безопасность
### Ограничения доступа
- Локальное простукивание выполняется с правами пользователя приложения
- Не требует root/administrator прав
- Подчиняется системным ограничениям сетевого доступа
### Логирование
- Результаты простукивания логируются в консоль
- Не сохраняются в файлы по умолчанию
- Можно отключить через параметр `verbose: false`
## Разработка и расширение
### Добавление новых протоколов
1. Создать функцию `knockProtocol()` в `src/main/main.js`
2. Добавить обработку в `performLocalKnock()`
3. Обновить документацию
### Настройка таймаутов
```javascript
// В src/main/main.js
function knockTcp(host, port, timeout = 10000) { // 10 секунд
// ...
}
```
### Добавление дополнительных опций
```javascript
// Расширить payload в IPC
{
targets: string[],
delay: string,
verbose: boolean,
timeout: number, // новый параметр
retries: number // новый параметр
}
```
---
## Пример обхода WireGuard
### Проблема
WireGuard активен, весь трафик идет через туннель, но нужно простучать порт через локальный шлюз.
### Решение
```json
{
"apiBase": "internal",
"gateway": "192.168.89.1",
"inlineTargets": "tcp:external-server.com:22",
"delay": "1s"
}
```
### Логи
```
Using localAddress 192.168.89.1 to bypass VPN/tunnel
Knocking TCP external-server.com:22 via 192.168.89.1
TCP connection to external-server.com:22 via 192.168.89.1 successful
```
---
**Версия документации**: 1.2
**Дата создания**: 2024
**Дата обновления**: 2024 (поддержка обхода VPN)
**Совместимость**: Electron Desktop App v1.0+

356
desktop/README.md Normal file
View File

@@ -0,0 +1,356 @@
# Knocker Desktop - Electron приложение
Независимое десктопное приложение для Port Knocker с полным функционалом веб-версии.
## 🚀 Быстрый старт
### Установка и запуск
```bash
cd desktop
npm install
npm run start
```
### Сборка для продакшена
```bash
# Сборка для текущей платформы
npm run build
# Сборка для конкретных платформ
npm run build:win # Windows
npm run build:linux # Linux
npm run build:mac # macOS
# Упаковка без установщика (для тестирования)
npm run pack
```
## 🏗️ Архитектура приложения
### Структура проекта
``` text
desktop/
├── src/
│ ├── main/ # Основной процесс Electron
│ │ ├── main.js # Точка входа, управление окнами
│ │ └── main.ts # TypeScript версия (опционально)
│ ├── preload/ # Preload скрипты (мост между main и renderer)
│ │ ├── preload.js # Безопасный API для renderer процесса
│ │ └── preload.ts # TypeScript версия
│ └── renderer/ # Процесс рендеринга (UI)
│ ├── index.html # HTML разметка
│ ├── styles.css # Стили
│ ├── renderer.js # Логика UI (ванильный JS)
│ └── renderer.ts # TypeScript версия
├── assets/ # Иконки для сборки
├── dist/ # Собранные приложения
├── package.json # Конфигурация и зависимости
└── README.md # Документация
```
### Как работает Electron
Electron состоит из двух основных процессов:
1. **Main Process (Основной процесс)** - `src/main/main.js`
- Управляет жизненным циклом приложения
- Создает и управляет окнами браузера
- Обеспечивает безопасный доступ к Node.js API
- Обрабатывает системные события (закрытие, фокус и т.д.)
2. **Renderer Process (Процесс рендеринга)** - `src/renderer/`
- Отображает пользовательский интерфейс
- Работает как обычная веб-страница (HTML/CSS/JS)
- Изолирован от Node.js API по соображениям безопасности
- Взаимодействует с main процессом через IPC (Inter-Process Communication)
3. **Preload Script (Preload скрипт)** - `src/preload/preload.js`
- Выполняется в renderer процессе, но имеет доступ к Node.js API
- Создает безопасный мост между main и renderer процессами
- Экспонирует только необходимые API через `contextBridge`
### Безопасность
Приложение использует современные принципы безопасности Electron:
- `contextIsolation: true` - изолирует контекст renderer от Node.js
- `nodeIntegration: false` - отключает прямой доступ к Node.js в renderer
- `sandbox: false` - позволяет preload скрипту работать (но только в preload)
## 🔧 Разработка
### Локальная разработка
```bash
npm run dev
```
Откроет приложение с включенными DevTools для отладки.
### Структура кода
#### Main Process (`src/main/main.js`)
```javascript
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
// Создание главного окна
function createWindow() {
mainWindow = new BrowserWindow({
width: 1100,
height: 800,
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true, // Безопасность
nodeIntegration: false, // Безопасность
sandbox: false // Для preload
}
});
}
// IPC обработчики для файловых операций
ipcMain.handle('file:open', async () => {
const res = await dialog.showOpenDialog({...});
// Возвращает файл в renderer процесс
});
```
#### Preload Script (`src/preload/preload.js`)
```javascript
const { contextBridge, ipcRenderer } = require('electron');
// Безопасный API для renderer процесса
contextBridge.exposeInMainWorld('api', {
openFile: () => ipcRenderer.invoke('file:open'),
saveAs: (payload) => ipcRenderer.invoke('file:saveAs', payload),
// ... другие методы
});
```
#### Renderer Process (`src/renderer/renderer.js`)
```javascript
// Используем безопасный API из preload
window.addEventListener('DOMContentLoaded', () => {
document.getElementById('openFile').addEventListener('click', async () => {
const result = await window.api.openFile();
// Обрабатываем результат
});
});
```
### Функциональность
#### Режимы работы
1. **Inline режим** - простые поля для ввода targets, delay, verbose
2. **YAML режим** - редактирование YAML конфигурации с поддержкой файлов
3. **Form режим** - табличная форма для добавления/удаления целей
#### Файловые операции
- Открытие файлов через системный диалог
- Сохранение файлов с предложением имени
- Автоматическое извлечение `path` из YAML
- Синхронизация между YAML и serverFilePath полем
#### HTTP API
- Вызовы к `http://localhost:8080/api/v1/knock-actions/*`
- Basic Authentication с пользователем `knocker`
- Выполнение knock операций
- Шифрование/дешифрование конфигураций
### Отладка
#### DevTools
DevTools автоматически открываются при запуске в режиме разработки (`npm run dev`).
#### Консольные сообщения
```javascript
// В renderer процессе
console.log('Debug info:', data);
// В main процессе
console.log('Main process log:', data);
```
#### IPC отладка
```javascript
// В preload можно добавить логирование
ipcRenderer.invoke('file:open').then(result => {
console.log('IPC result:', result);
});
```
## 📦 Сборка и распространение
### Electron Builder конфигурация
В `package.json` настроена конфигурация `electron-builder`:
```json
{
"build": {
"appId": "com.knocker.desktop",
"productName": "Knocker Desktop",
"files": ["src/**/*", "node_modules/**/*"],
"win": {
"target": "nsis",
"icon": "assets/icon.ico"
},
"linux": {
"target": "AppImage",
"icon": "assets/icon.png"
},
"mac": {
"target": "dmg",
"icon": "assets/icon.icns"
}
}
}
```
### Типы сборки
- **NSIS** (Windows) - установщик с мастером установки
- **AppImage** (Linux) - портативное приложение
- **DMG** (macOS) - образ диска для установки
### Команды сборки
```bash
npm run build # Сборка для текущей платформы
npm run build:win # Сборка для Windows
npm run build:linux # Сборка для Linux
npm run build:mac # Сборка для macOS
npm run pack # Упаковка без установщика
npm run dist # Сборка без публикации
```
### Иконки
Поместите иконки в папку `assets/`:
- `icon.ico` - для Windows (256x256)
- `icon.png` - для Linux (512x512)
- `icon.icns` - для macOS (512x512)
## 🔄 Интеграция с веб-версией
### Общие компоненты
- HTTP API остается тем же (`/api/v1/knock-actions/*`)
- YAML формат конфигурации идентичен
- Логика шифрования/дешифрования совместима
### Различия
- **Файловые операции**: Electron dialog вместо браузерных File API
- **UI библиотеки**: ванильный JS вместо Angular/PrimeNG
- **Автосохранение**: localStorage в веб-версии, файловая система в desktop
- **FSA API**: не нужен в desktop версии
### Миграция данных
Пользователи могут переносить конфигурации между версиями через:
- Экспорт/импорт YAML файлов
- Копирование содержимого между интерфейсами
- Использование одинаковых server paths
## 🐛 Устранение неполадок
### Частые проблемы
#### Приложение не запускается
```bash
# Проверьте зависимости
npm install
# Очистите node_modules
rm -rf node_modules package-lock.json
npm install
```
#### DevTools не открываются
Убедитесь, что в `src/main/main.js` есть строка:
```javascript
mainWindow.webContents.openDevTools();
```
#### Файлы не открываются
Проверьте, что backend сервер запущен на `http://localhost:8080`
#### Сборка не работает
```bash
# Очистите dist папку
rm -rf dist
# Пересоберите
npm run build
```
### Логи отладки
#### Main процесс
Логи main процесса видны в терминале, где запущено приложение.
#### Renderer процесс
Логи renderer процесса видны в DevTools Console.
#### IPC сообщения
Можно добавить логирование в preload для отладки IPC:
```javascript
const originalInvoke = ipcRenderer.invoke;
ipcRenderer.invoke = function(channel, ...args) {
console.log(`IPC: ${channel}`, args);
return originalInvoke.call(this, channel, ...args);
};
```
## 📚 Дополнительные ресурсы
- [Electron Documentation](https://www.electronjs.org/docs)
- [Electron Builder](https://www.electron.build/)
- [Context Isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation)
- [IPC Communication](https://www.electronjs.org/docs/latest/tutorial/ipc)
## 🤝 Вклад в разработку
1. Форкните репозиторий
2. Создайте ветку для новой функции
3. Внесите изменения
4. Протестируйте на всех платформах
5. Создайте Pull Request
### Тестирование
```bash
# Тест на текущей платформе
npm run dev
# Сборка для тестирования
npm run pack
# Проверка на других платформах
npm run build:win
npm run build:linux
npm run build:mac
```

913
desktop/USAGE_GUIDE.md Normal file
View File

@@ -0,0 +1,913 @@
# Руководство по использованию Desktop приложения
## Обзор
Desktop версия knocker-приложения предоставляет полный функционал порт-простукивания (port knocking) в виде автономного Electron приложения. Поддерживает как работу через внешний API сервер, так и локальное простукивание через Node.js.
## Содержание
1. [Установка и запуск](#установка-и-запуск)
2. [Режимы работы](#режимы-работы)
3. [Конфигурация](#конфигурация)
4. [API контракты](#api-контракты)
5. [Локальное простукивание](#локальное-простукивание)
6. [Интерфейс пользователя](#интерфейс-пользователя)
7. [Примеры использования](#примеры-использования)
8. [Устранение неполадок](#устранение-неполадок)
9. [Разработка](#разработка)
---
## Установка и запуск
### Предварительные требования
- **Node.js** v18+
- **npm** v8+
- **Операционная система**: Windows, macOS, Linux
### Установка зависимостей
```bash
cd desktop
npm install
```
### Режимы запуска
#### Разработка
```bash
npm run dev
```
#### Сборка для продакшена
```bash
npm run build
```
#### Создание исполняемых файлов
```bash
npm run dist
```
#### Упаковка для конкретной платформы
```bash
# Windows
npm run dist:win
# macOS
npm run dist:mac
# Linux
npm run dist:linux
```
### Переменные окружения
```bash
# Базовый URL API (опционально)
export KNOCKER_DESKTOP_API_BASE="http://localhost:8080/api/v1"
# Запуск в режиме разработки
npm run dev
```
---
## Режимы работы
### 1. API режим (по умолчанию)
Приложение подключается к внешнему HTTP API серверу для выполнения операций простукивания.
**Активация:**
- Установить корректный `apiBase` URL в настройках
- Например: `http://localhost:8080/api/v1`
**Возможности:**
- ✅ HTTP API простукивание
- ✅ Шифрование/расшифровка YAML
- ✅ Полная функциональность backend сервера
### 2. Локальный режим
Приложение выполняет простукивание напрямую через Node.js сокеты без внешнего API.
**Активация:**
- Установить `apiBase` в `""` (пустая строка)
- Или установить `apiBase` в `"internal"`
**Возможности:**
- ✅ TCP простукивание
- ✅ UDP простукивание
- ❌ Шифрование/расшифровка (недоступно)
- ✅ Автономная работа
#### Локальное простукивание с gateway (Rust/Go helper)
- Если указано `gateway` (IP или имя интерфейса), Electron автоматически запускает встроенный helper из папки `bin/`:
- `knock-local-rust` (Rust) — используется приоритетно, если присутствует
- `knock-local` (Go) — используется как fallback, если Rust-бинарь отсутствует
- Оба helper-а на Linux используют `SO_BINDTODEVICE` для надежной привязки к интерфейсу и обхода VPN/WireGuard.
- Если `gateway` не указан — используется встроенная Node-реализация без привязки к интерфейсу.
Требования при разработке:
- Для Rust-хелпера ничего дополнительно не требуется (собирается скриптом `npm run rust:build`).
- Для Go-хелпера должен быть установлен Go toolchain (скрипт `npm run go:build`).
В релизных сборках оба бинаря включаются автоматически.
Важно (TCP): привязка интерфейса (`SO_BINDTODEVICE`) устанавливается до `connect()`. Это гарантирует, что исходящее TCP-соединение пойдёт через указанный интерфейс, а не в туннель.
### 3. Переключение между режимами
**API → Локальный:**
1. Открыть настройки (Ctrl/Cmd + ,)
2. Установить `apiBase: ""` или `apiBase: "internal"`
3. Сохранить настройки
4. Перезапустить приложение
**Локальный → API:**
1. Открыть настройки
2. Установить корректный `apiBase` URL
3. Сохранить настройки
4. Перезапустить приложение
---
## Конфигурация
### Файл конфигурации
Конфигурация сохраняется в: `~/.config/[app-name]/config.json`
**Структура конфигурации:**
```json
{
"apiBase": "http://localhost:8080/api/v1",
"gateway": "default-gateway",
"inlineTargets": "tcp:127.0.0.1:22;tcp:192.168.1.1:80",
"delay": "1s"
}
```
### Поля конфигурации
| Поле | Тип | Описание | По умолчанию |
|------|-----|----------|--------------|
| `apiBase` | string | URL API сервера или "internal" | `http://localhost:8080/api/v1` |
| `gateway` | string | Шлюз по умолчанию | `""` |
| `inlineTargets` | string | Цели в inline формате | `""` |
| `delay` | string | Задержка между целями | `"1s"` |
### Редактирование конфигурации
**Через интерфейс:**
1. Меню → Настройки
2. Редактирование JSON в текстовом поле
3. Кнопка "Сохранить"
**Программно:**
```javascript
// Получить значение
const apiBase = await window.api.getConfig('apiBase');
// Установить значение
await window.api.setConfig('apiBase', 'http://new-api.com');
// Получить всю конфигурацию
const config = await window.api.getAllConfig();
// Установить всю конфигурацию
await window.api.setAllConfig(newConfig);
```
---
## API контракты
### HTTP API Endpoints (для API режима)
#### 1. Выполнение простукивания
**POST** `/api/v1/knock-actions/execute`
**Headers:**
``` text
Content-Type: application/json
Authorization: Basic <base64(username:password)>
```
**Body (YAML режим):**
```json
{
"config_yaml": "targets:\n - protocol: tcp\n host: 127.0.0.1\n ports: [22, 80]\ndelay: 1s"
}
```
**Body (Inline режим):**
```json
{
"targets": "tcp:127.0.0.1:22;tcp:192.168.1.1:80",
"delay": "1s",
"verbose": true,
"waitConnection": false,
"gateway": "gateway.com"
}
```
**Body (Form режим):**
```json
{
"targets": "tcp:127.0.0.1:22;tcp:192.168.1.1:80",
"delay": "2s",
"verbose": true,
"waitConnection": true
}
```
**Response:**
```json
{
"success": true,
"message": "Knocking completed successfully"
}
```
#### 2. Шифрование YAML
**POST** `/api/v1/knock-actions/encrypt`
**Headers:**
``` text
Content-Type: application/json
Authorization: Basic <base64(username:password)>
```
**Body:**
```json
{
"yaml": "targets:\n - protocol: tcp\n host: 127.0.0.1\n ports: [22]"
}
```
**Response:**
```json
{
"encrypted": "ENCRYPTED:base64-encoded-data"
}
```
#### 3. Расшифровка YAML
**POST** `/api/v1/knock-actions/decrypt`
**Headers:**
``` text
Content-Type: application/json
Authorization: Basic <base64(username:password)>
```
**Body:**
```json
{
"encrypted": "ENCRYPTED:base64-encoded-data"
}
```
**Response:**
```json
{
"yaml": "targets:\n - protocol: tcp\n host: 127.0.0.1\n ports: [22]"
}
```
### IPC API (для локального режима)
#### Локальное простукивание
**Channel:** `knock:local`
**Request:**
```javascript
{
targets: string[], // ["tcp:127.0.0.1:22", "udp:192.168.1.1:53"]
delay: string, // "1s", "2m", "500ms"
verbose: boolean, // true/false
gateway: string // "192.168.1.1" (опционально)
}
```
**Response:**
```javascript
{
success: boolean,
results: [
{
target: string,
success: boolean,
message: string
}
],
summary: {
total: number,
successful: number,
failed: number
}
}
```
---
## Локальное простукивание _
### Поддерживаемые протоколы
- **TCP** - создание соединения и немедленное закрытие
- **UDP** - отправка пакета данных (fire-and-forget)
### Формат целей
``` text
protocol:host:port[:gateway]
```
**Примеры:**
- `tcp:127.0.0.1:22`
- `udp:192.168.1.1:53`
- `tcp:example.com:80:gateway.com`
### Поддержка Gateway
Gateway можно указать двумя способами:
1. **В строке цели**: `tcp:host:port:gateway_ip`
2. **Глобально через поле Gateway**: используется для всех целей, если не указан в самой цели
**Приоритет gateway:**
- Gateway из строки цели имеет приоритет над глобальным
- Если gateway не указан, используется системный маршрут по умолчанию
**Примеры использования gateway:**
``` text
tcp:192.168.1.100:22:192.168.1.1 # Через конкретный gateway
tcp:127.0.0.1:22 # Системный маршрут
udp:example.com:53 # Системный маршрут
```
### Формат задержек
- `1s` - 1 секунда
- `500ms` - 500 миллисекунд (не поддерживается, используйте `0.5s`)
- `2m` - 2 минуты
- `1h` - 1 час
### Таймауты
- **TCP**: 5 секунд по умолчанию
- **UDP**: 5 секунд по умолчанию
### Примеры локального простукивания
```javascript
// Простое TCP простукивание
const result = await window.api.localKnock({
targets: ["tcp:127.0.0.1:22"],
delay: "1s",
verbose: true
});
// Множественные цели
const result = await window.api.localKnock({
targets: [
"tcp:127.0.0.1:22",
"udp:192.168.1.1:53",
"tcp:example.com:80"
],
delay: "2s",
verbose: false
});
```
---
## Интерфейс пользователя
### Главное окно
#### Поля конфигурации _
- **API URL** - адрес API сервера или "internal" для локального режима
- **Gateway** - шлюз по умолчанию
- **Password** - пароль для аутентификации
#### Режимы работы _
1. **Inline** - простой текстовый формат целей
2. **YAML** - структурированная YAML конфигурация
3. **Form** - графический редактор целей
#### Inline режим
- **Targets** - цели в формате `protocol:host:port;protocol:host:port`
- **Delay** - задержка между целями
- **Verbose** - подробный вывод
- **Wait Connection** - ожидание соединения
- **Gateway** - шлюз
#### YAML режим
- **Config YAML** - YAML конфигурация
- **Server File Path** - путь к файлу на сервере
- **Encrypt/Decrypt** - шифрование/расшифровка
#### Form режим
- **Targets List** - список целей с возможностью редактирования
- **Add Target** - добавление новой цели
- **Remove** - удаление цели
### Меню приложения
#### Файл
- **Открыть файл** - загрузка YAML конфигурации
- **Сохранить как** - сохранение текущей конфигурации
- **Выход** - закрытие приложения
#### Настройки
- **Настройки** - открытие окна конфигурации
#### Справка
- **О программе** - информация о версии
- **Документация** - ссылки на документацию
### Окно настроек
#### Редактирование конфигурации _
- **JSON Editor** - многострочное поле для редактирования
- **Save** - сохранение изменений
- **Return** - возврат к главному окну
#### Валидация
- Автоматическая проверка JSON синтаксиса
- Отображение ошибок валидации
- Предотвращение сохранения некорректных данных
---
## Примеры использования
### Пример 1: Базовое простукивание SSH
**Цель:** Открыть SSH доступ к серверу
**Конфигурация:**
```json
{
"apiBase": "internal",
"gateway": "",
"inlineTargets": "tcp:192.168.1.100:22",
"delay": "1s"
}
```
**Шаги:**
1. Установить режим "Inline"
2. Ввести цель: `tcp:192.168.1.100:22`
3. Установить задержку: `1s`
4. Нажать "Выполнить"
### Пример 2: Множественные цели
**Цель:** Простучать несколько сервисов
**Конфигурация:**
``` text
tcp:server1.com:22;tcp:server1.com:80;udp:server2.com:53
```
**Задержка:** `2s`
### Пример 3: YAML конфигурация
**Файл конфигурации:**
```yaml
targets:
- protocol: tcp
host: 127.0.0.1
ports: [22, 80, 443]
wait_connection: true
- protocol: udp
host: 192.168.1.1
ports: [53, 123]
delay: 1s
path: /etc/knocker/config.yaml
```
### Пример 4: Шифрование конфигурации
**Шаги:**
1. Создать YAML конфигурацию
2. Установить пароль
3. Нажать "Зашифровать"
4. Сохранить зашифрованный файл
### Пример 5: Локальный режим с множественными целями
**Конфигурация для локального режима:**
```json
{
"apiBase": "internal",
"inlineTargets": "tcp:127.0.0.1:22;tcp:127.0.0.1:80;udp:127.0.0.1:53",
"delay": "1s"
}
```
### Пример 6: Использование Gateway
**Простукивание через определенный интерфейс:**
```json
{
"apiBase": "internal",
"gateway": "192.168.1.1",
"inlineTargets": "tcp:192.168.1.100:22",
"delay": "1s"
}
```
**Смешанное использование gateway:**
``` text
tcp:127.0.0.1:22;tcp:192.168.1.100:22:192.168.1.1;udp:example.com:53
```
- Первая цель: без gateway (системный маршрут)
- Вторая цель: через gateway 192.168.1.1
- Третья цель: без gateway
Замечания по ошибкам:
- Если указан несуществующий интерфейс в `gateway`, helper вернёт критическую ошибку и код выхода 1.
- При `waitConnection: false` сетевые отказы соединения трактуются как предупреждения, но ошибки привязки к интерфейсу — всегда ошибки.
---
## Устранение неполадок
### Общие проблемы
#### Проблема: "API URL не доступен"
**Симптомы:**
- Ошибки подключения к API
- Таймауты при выполнении операций
**Решения:**
1. Проверить доступность API сервера
2. Проверить правильность URL
3. Проверить настройки файрвола
4. Переключиться в локальный режим
#### Проблема: "Неправильный пароль"
**Симптомы:**
- HTTP 401 ошибки
- Отказ в доступе при шифровании
**Решения:**
1. Проверить правильность пароля
2. Убедиться в корректности base64 кодирования
3. Проверить настройки аутентификации на сервере
#### Проблема: "Файл не найден"
**Симптомы:**
- Ошибки при открытии файлов
- "File not found" при сохранении
**Решения:**
1. Проверить права доступа к файлам
2. Убедиться в существовании директорий
3. Проверить путь к файлу
### Проблемы локального режима
#### Проблема: "No targets provided"
**Причина:** Не удалось извлечь цели из конфигурации
**Решение:**
1. Проверить заполнение поля targets
2. Убедиться в корректности формата
3. Проверить режим работы (inline/yaml/form)
#### Проблема: "Unsupported protocol"
**Причина:** Использован неподдерживаемый протокол
**Решение:**
- Использовать только `tcp` или `udp`
- Проверить синтаксис: `protocol:host:port`
#### Проблема: "Connection timeout"
**Причина:** Цель недоступна или заблокирована
**Решение:**
1. Проверить доступность цели
2. Проверить настройки файрвола
3. Убедиться в правильности IP/порта
### Проблемы конфигурации
#### Проблема: "Invalid JSON"
**Симптомы:**
- Ошибки при сохранении настроек
- Невозможность загрузить конфигурацию
**Решения:**
1. Проверить синтаксис JSON
2. Использовать валидатор JSON
3. Проверить экранирование специальных символов
#### Проблема: "Настройки не сохраняются"
**Причина:** Проблемы с правами доступа
**Решение:**
1. Проверить права записи в директорию конфигурации
2. Запустить от имени администратора (если необходимо)
3. Проверить свободное место на диске
### Диагностика
#### Логи приложения
```bash
# Windows
%APPDATA%/[app-name]/logs/
# macOS
~/Library/Logs/[app-name]/
# Linux
~/.config/[app-name]/logs/
```
#### DevTools
1. Открыть DevTools (F12)
2. Проверить Console на ошибки
3. Проверить Network для API запросов
4. Проверить Application → Local Storage
#### Командная строка
```bash
# Запуск с отладкой
npm run dev -- --enable-logging
# Проверка переменных окружения
echo $KNOCKER_DESKTOP_API_BASE
```
---
## Разработка _
### Структура проекта
``` text
desktop/
├── src/
│ ├── main/ # Main процесс
│ │ └── main.js # Основная логика
│ ├── preload/ # Preload скрипты
│ │ └── preload.js # IPC мост
│ └── renderer/ # Renderer процесс
│ ├── index.html # Главная страница
│ ├── renderer.js # UI логика
│ ├── settings.html # Страница настроек
│ └── settings.js # Логика настроек
├── package.json # Зависимости и скрипты
├── electron-builder.yml # Конфигурация сборки
└── README.md # Документация
```
### Ключевые файлы
#### `src/main/main.js`
- Создание и управление окнами
- IPC обработчики
- Локальное простукивание
- Файловые операции
#### `src/preload/preload.js`
- Безопасный мост между процессами
- Экспорт API в renderer
#### `src/renderer/renderer.js`
- UI логика
- Обработка пользовательского ввода
- HTTP запросы к API
### Добавление новых функций
#### 1. Новый IPC метод
**В main.js:**
```javascript
ipcMain.handle('new:method', async (_e, payload) => {
// Логика метода
return { success: true, data: result };
});
```
**В preload.js:**
```javascript
contextBridge.exposeInMainWorld('api', {
// ... существующие методы
newMethod: async (payload) => ipcRenderer.invoke('new:method', payload)
});
```
**В renderer.js:**
```javascript
const result = await window.api.newMethod(data);
```
#### 2. Новый UI элемент
**В index.html:**
```html
<button id="newButton">Новая функция</button>
```
**В renderer.js:**
```javascript
qsi('#newButton')?.addEventListener('click', async () => {
// Логика обработки
});
```
### Тестирование
#### Unit тесты
```bash
npm test
```
#### Интеграционные тесты
```bash
npm run test:integration
```
#### E2E тесты
```bash
npm run test:e2e
```
### Сборка и деплой
#### Локальная сборка
```bash
npm run build
```
#### Создание дистрибутивов
```bash
npm run dist
```
#### Автоматические релизы
```bash
npm run release
```
### Отладка
#### DevTools _
- **Main процесс**: `--inspect` флаг
- **Renderer процесс**: F12 в приложении
#### Логирование
```javascript
console.log('Debug info:', data);
console.error('Error:', error);
```
#### Профилирование
```bash
npm run dev -- --enable-profiling
```
---
## Безопасность
### Рекомендации
1. **Пароли**: Используйте сильные пароли для аутентификации
2. **Сеть**: Ограничьте доступ к API серверу
3. **Файлы**: Не храните пароли в открытом виде
4. **Обновления**: Регулярно обновляйте приложение
### Ограничения
- Локальное простукивание выполняется с правами пользователя
- Не требует root/administrator прав
- Подчиняется системным ограничениям сетевого доступа
---
## Поддержка
### Контакты
- **Документация**: [LOCAL_KNOCKING.md](./LOCAL_KNOCKING.md)
- **Исходный код**: [GitHub Repository]
- **Issues**: [GitHub Issues]
### Версии
- **Текущая версия**: 1.0
- **Electron**: v28+
- **Node.js**: v18+
### Лицензия
[Указать лицензию]
---
**Версия документации**: 1.0
**Дата создания**: 2024
**Совместимость**: Electron Desktop App v1.0+

4923
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
desktop/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "desktop",
"version": "1.0.0",
"description": "",
"main": "src/main/main.js",
"scripts": {
"start": "electron .",
"dev": "npm run rust:build && npm run go:build && electron .",
"build": "npm run rust:build && npm run go:build && electron-builder",
"build:win": "electron-builder --win",
"build:linux": "electron-builder --linux",
"build:mac": "electron-builder --mac",
"dist": "electron-builder --publish=never",
"pack": "electron-builder --dir",
"test": "echo \"No tests\" && exit 0",
"go:build": "bash -lc 'mkdir -p bin && cd ../back && go build -o ../desktop/bin/knock-local ./cmd/knock-local'",
"rust:build": "bash -lc 'mkdir -p bin && cd ../rust-knocker && cargo build --release && cp target/release/knock-local ../desktop/bin/knock-local-rust'"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"build": {
"appId": "com.knocker.desktop",
"productName": "Knocker Desktop",
"directories": {
"output": "dist"
},
"files": [
"src/**/*",
"node_modules/**/*",
"bin/**/*"
],
"extraResources": [{ "from": "bin", "to": "bin", "filter": ["**/*"] }],
"win": {
"target": "nsis",
"icon": "assets/icon.ico"
},
"linux": {
"target": "AppImage",
"icon": "assets/icon.png"
},
"mac": {
"target": "dmg",
"icon": "assets/icon.icns"
}
},
"devDependencies": {
"electron": "^28.3.3",
"electron-builder": "^26.0.12"
},
"dependencies": {
"axios": "^1.12.2",
"js-yaml": "^4.1.0"
}
}

666
desktop/src/main/main.js Normal file
View File

@@ -0,0 +1,666 @@
const { app, BrowserWindow, ipcMain, dialog, shell, Menu } = require('electron');
const path = require('path');
const fs = require('fs');
const net = require('net');
const dgram = require('dgram');
const os = require('os');
let mainWindow = null;
let settingsWindow = null;
// --- Persistent config (userData/config.json) ---
let configCache = null;
function getConfigPath() {
return path.join(app.getPath('userData'), 'config.json');
}
function loadConfig() {
if (configCache) return configCache;
const cfgPath = getConfigPath();
try {
if (fs.existsSync(cfgPath)) {
const parsed = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
configCache = parsed || {};
return configCache;
}
} catch (e) {
console.warn('Failed to read config file:', e);
}
configCache = {};
return configCache;
}
function saveConfig(partial) {
const current = loadConfig();
const next = { ...current, ...partial };
configCache = next;
try {
fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true });
fs.writeFileSync(getConfigPath(), JSON.stringify(next, null, 2), 'utf-8');
return { ok: true };
} catch (e) {
console.error('Failed to save config file:', e);
return { ok: false, error: (e?.message) || String(e) };
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1100,
height: 800,
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
});
const indexPath = path.join(__dirname, '../renderer/index.html');
mainWindow.loadFile(indexPath);
// Включаем DevTools для разработки
mainWindow.webContents.openDevTools();
mainWindow.on('closed', () => {
mainWindow = null;
});
// Создаем меню
createMenu();
}
function createSettingsWindow() {
if (settingsWindow) {
settingsWindow.focus();
return;
}
settingsWindow = new BrowserWindow({
width: 600,
height: 720,
parent: mainWindow,
modal: true,
resizable: true,
closable: true,
minimizable: false,
maximizable: false,
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
});
const settingsPath = path.join(__dirname, '../renderer/settings.html');
settingsWindow.loadFile(settingsPath);
settingsWindow.on('closed', () => {
settingsWindow = null;
// Возвращаем фокус на главное окно
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.focus();
}
});
}
function createMenu() {
const template = [
{
label: 'Файл',
submenu: [
{
label: 'Настройки',
accelerator: 'CmdOrCtrl+,',
click: createSettingsWindow
},
{ type: 'separator' },
{
label: 'Выход',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
click: () => {
app.quit();
}
}
]
},
{
label: 'Вид',
submenu: [
{ role: 'reload', label: 'Перезагрузить' },
{ role: 'forceReload', label: 'Принудительная перезагрузка' },
{ role: 'toggleDevTools', label: 'Инструменты разработчика' },
{ type: 'separator' },
{ role: 'resetZoom', label: 'Сбросить масштаб' },
{ role: 'zoomIn', label: 'Увеличить' },
{ role: 'zoomOut', label: 'Уменьшить' },
{ type: 'separator' },
{ role: 'togglefullscreen', label: 'Полный экран' }
]
},
{
label: 'Окно',
submenu: [
{ role: 'minimize', label: 'Свернуть' },
{ role: 'close', label: 'Закрыть' }
]
}
];
if (process.platform === 'darwin') {
template.unshift({
label: app.getName(),
submenu: [
{ role: 'about', label: 'О программе' },
{ type: 'separator' },
{ role: 'services', label: 'Сервисы' },
{ type: 'separator' },
{ role: 'hide', label: 'Скрыть' },
{ role: 'hideOthers', label: 'Скрыть остальные' },
{ role: 'unhide', label: 'Показать все' },
{ type: 'separator' },
{ role: 'quit', label: 'Выход' }
]
});
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
// Глобальные обработчики ошибок
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception in main process:', error);
// Не завершаем приложение, просто логируем
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection in main process:', reason);
// Не завершаем приложение, просто логируем
});
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
// --- Config IPC ---
ipcMain.handle('config:get', async (_e, key) => {
const cfg = loadConfig();
if (key) return cfg[key];
return cfg;
});
ipcMain.handle('config:set', async (_e, key, value) => {
return saveConfig({ [key]: value });
});
ipcMain.handle('config:getAll', async () => {
return loadConfig();
});
ipcMain.handle('config:setAll', async (_e, newConfig) => {
return saveConfig(newConfig);
});
ipcMain.handle('settings:close', () => {
if (settingsWindow) {
settingsWindow.close();
return { ok: true };
}
return { ok: false, error: 'Settings window not found' };
});
// --- Local Port Knocking Implementation ---
function parseTarget(targetStr) {
const parts = targetStr.split(':');
if (parts.length < 3) {
throw new Error(`Invalid target format: ${targetStr}`);
}
return {
protocol: parts[0]?.toLowerCase() || 'tcp',
host: parts[1] || '127.0.0.1',
port: parseInt(parts[2]) || 22,
gateway: parts[3] || null
};
}
function parseDelay(delayStr) {
const match = delayStr?.match(/^(\d+)([smh]?)$/);
if (!match) return 1000; // default 1 second
const value = parseInt(match[1]);
const unit = match[2] || 's';
switch (unit) {
case 's': return value * 1000;
case 'm': return value * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
default: return value * 1000;
}
}
function knockTcp(host, port, timeout = 5000, gateway = null) {
return new Promise((resolve, reject) => {
const socket = new net.Socket();
let resolved = false;
function safeResolve(result) {
if (resolved) {
return;
}
resolved = true;
try {
socket.destroy();
} catch (e) {
// Ignore errors during cleanup
}
resolve(result);
}
socket.setTimeout(timeout);
socket.on('connect', () => {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
const localAddr = socket.localAddress;
const localPort = socket.localPort;
console.log(`TCP connected from ${localAddr}:${localPort} to ${host}:${port}`);
safeResolve({ success: true, message: `TCP connection to ${host}:${port}${gatewayInfo} successful (from ${localAddr}:${localPort})` });
});
socket.on('timeout', () => {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `TCP connection to ${host}:${port}${gatewayInfo} timeout` });
});
socket.on('error', (err) => {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `TCP connection to ${host}:${port}${gatewayInfo} failed: ${err.message}` });
});
socket.on('close', () => {
// Socket was closed, nothing to do
});
try {
if (gateway?.trim()) {
// Для TCP используем localAddress в connect() для обхода VPN
console.log(`Using localAddress ${gateway.trim()} to bypass VPN/tunnel`);
socket.connect({
port,
host: host,
localAddress: gateway.trim()
});
} else {
// Обычное подключение без привязки
socket.connect(port, host);
}
} catch (error) {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `TCP connection to ${host}:${port}${gatewayInfo} failed: ${error.message}` });
}
});
}
function knockUdp(host, port, timeout = 5000, gateway = null) {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket('udp4');
const message = Buffer.from('knock');
let resolved = false;
function safeResolve(result) {
if (!resolved) {
resolved = true;
socket.close();
resolve(result);
}
}
socket.on('error', (err) => {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `UDP packet to ${host}:${port}${gatewayInfo} failed: ${err.message}` });
});
// Если указан gateway, привязываем сокет к локальному адресу для обхода VPN/туннелей
if (gateway && gateway.trim()) {
try {
socket.bind(0, gateway.trim());
console.log(`UDP socket bound to localAddress ${gateway.trim()} to bypass VPN/tunnel`);
} catch (bindError) {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `UDP socket bind to ${gateway}${gatewayInfo} failed: ${bindError.message}` });
return;
}
}
socket.send(message, 0, message.length, port, host, (err) => {
if (err) {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `UDP packet to ${host}:${port}${gatewayInfo} failed: ${err.message}` });
return;
}
// UDP is fire-and-forget, so we consider it successful if we can send
const gatewayInfo = gateway ? ` via ${gateway}` : '';
const localAddr = socket.address()?.address;
const localPort = socket.address()?.port;
console.log(`UDP packet sent from ${localAddr}:${localPort} to ${host}:${port}`);
safeResolve({ success: true, message: `UDP packet sent to ${host}:${port}${gatewayInfo} (from ${localAddr}:${localPort})` });
});
// Set timeout for UDP operations
const timeoutId = setTimeout(() => {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: true, message: `UDP packet sent to ${host}:${port}${gatewayInfo} (timeout reached)` });
}, timeout);
// Clean up timeout if socket resolves earlier
socket.on('close', () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
});
}
async function performLocalKnock(targets, delay, verbose = true, gateway = null) {
const results = [];
const delayMs = parseDelay(delay);
try {
for (let i = 0; i < targets.length; i++) {
const targetStr = targets[i];
try {
const target = parseTarget(targetStr);
// Используем gateway из цели или глобальный gateway
const effectiveGateway = target.gateway || gateway;
if (verbose) {
console.log(`Knocking ${target.protocol.toUpperCase()} ${target.host}:${target.port}${effectiveGateway ? ` via ${effectiveGateway}` : ''}`);
}
let result;
try {
if (target.protocol === 'tcp') {
result = await knockTcp(target.host, target.port, 5000, effectiveGateway);
} else if (target.protocol === 'udp') {
result = await knockUdp(target.host, target.port, 5000, effectiveGateway);
} else {
result = { success: false, message: `Unsupported protocol: ${target.protocol}` };
}
} catch (knockError) {
result = {
success: false,
message: `Knock operation failed: ${knockError.message}`
};
}
results.push({
target: targetStr,
...result
});
// Add delay between targets (except for the last one)
if (i < targets.length - 1 && delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
} catch (error) {
console.error(`Error processing target ${targetStr}:`, error);
results.push({
target: targetStr,
success: false,
message: `Error: ${error.message}`
});
}
}
} catch (error) {
console.error('Critical error in performLocalKnock:', error);
throw error;
}
return results;
}
// Диагностика сетевых интерфейсов
ipcMain.handle('network:interfaces', async () => {
try {
const interfaces = os.networkInterfaces();
const result = {};
for (const [name, addrs] of Object.entries(interfaces)) {
result[name] = addrs.map(addr => ({
address: addr.address,
family: addr.family,
internal: addr.internal,
mac: addr.mac
}));
}
return { success: true, interfaces: result };
} catch (error) {
return { success: false, error: error.message };
}
});
// Тест подключения с конкретным localAddress
ipcMain.handle('network:test-connection', async (_e, payload) => {
try {
const { host, port, localAddress } = payload;
return new Promise((resolve) => {
const socket = new net.Socket();
let resolved = false;
function safeResolve(result) {
if (!resolved) {
resolved = true;
socket.destroy();
resolve(result);
}
}
socket.setTimeout(5000);
socket.on('connect', () => {
const localAddr = socket.localAddress;
const localPort = socket.localPort;
safeResolve({
success: true,
message: `Connection successful from ${localAddr}:${localPort}`,
localAddress: localAddr,
localPort: localPort
});
});
socket.on('error', (err) => {
safeResolve({ success: false, error: err.message });
});
socket.on('timeout', () => {
safeResolve({ success: false, error: 'Connection timeout' });
});
try {
if (localAddress) {
console.log(`Testing connection to ${host}:${port} with localAddress ${localAddress}`);
socket.connect({
port: port,
host: host,
localAddress: localAddress
});
} else {
socket.connect(port, host);
}
} catch (error) {
safeResolve({ success: false, error: error.message });
}
});
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('knock:local', async (_e, payload) => {
try {
// Валидация входных данных
if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload provided' };
}
const { targets, delay, verbose, gateway } = payload;
if (!targets || !Array.isArray(targets) || targets.length === 0) {
return { success: false, error: 'No targets provided' };
}
// Валидация каждого target
const validTargets = targets.filter(target => {
return typeof target === 'string' && target.trim().length > 0;
});
if (validTargets.length === 0) {
return { success: false, error: 'No valid targets provided' };
}
// Если задан gateway, используем Go-хелпер (поддерживает SO_BINDTODEVICE)
if ((gateway && String(gateway).trim()) || validTargets.some(t => t.split(':').length >= 4)) {
const { spawn } = require('child_process');
// Ищем собранный бинарь внутри Electron-пакета
// Сначала пробуем Rust версию, потом Go версию
const devRustBin = path.resolve(__dirname, '../../bin/knock-local-rust');
const prodRustBin = path.resolve(process.resourcesPath || path.resolve(__dirname, '../../'), 'bin/knock-local-rust');
const devGoBin = path.resolve(__dirname, '../../bin/knock-local');
const prodGoBin = path.resolve(process.resourcesPath || path.resolve(__dirname, '../../'), 'bin/knock-local');
let helperExec;
if (fs.existsSync(devRustBin)) {
helperExec = devRustBin;
console.log('Using Rust knock-local helper (dev)');
} else if (fs.existsSync(prodRustBin)) {
helperExec = prodRustBin;
console.log('Using Rust knock-local helper (prod)');
} else if (fs.existsSync(devGoBin)) {
helperExec = devGoBin;
console.log('Using Go knock-local helper (dev)');
} else {
helperExec = prodGoBin;
console.log('Using Go knock-local helper (prod)');
}
const req = {
targets: validTargets,
delay: delay || '1s',
// Принудительно отключаем verbose у хелпера, чтобы stdout был чисто JSON
verbose: false,
gateway: gateway || ''
};
const input = JSON.stringify(req);
const child = spawn(helperExec, [], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.stdin.write(input);
child.stdin.end();
// Таймаут на 15 секунд - вдруг что-то пойдёт не так
const timeout = setTimeout(() => {
child.kill('SIGTERM');
}, 15_000);
const code = await new Promise(resolve => child.on('close', resolve));
clearTimeout(timeout);
if (code !== 0) {
return { success: false, error: `go helper exited with code ${code}: ${stderr || stdout}` };
}
try {
// Извлекаем последнюю JSON-строку из stdout (в случае если есть текстовые логи)
const lines = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const jsonLine = [...lines].reverse().find(l => l.startsWith('{') && l.endsWith('}')) || stdout.trim();
const parsed = JSON.parse(jsonLine);
if (parsed?.success) {
return { success: true, results: [], summary: { total: validTargets.length, successful: validTargets.length, failed: 0 } };
}
return { success: false, error: parsed?.error || 'unknown helper error' };
} catch (e) {
return { success: false, error: `failed to parse helper output: ${e.message}`, raw: stdout };
}
}
const results = await performLocalKnock(validTargets, delay || '1s', Boolean(verbose), gateway || null);
return {
success: true,
results: results,
summary: {
total: results.length,
successful: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length
}
};
} catch (error) {
console.error('Local knock error:', error);
return {
success: false,
error: error.message || 'Unknown error occurred'
};
}
});
// File dialogs and fs operations
ipcMain.handle('file:open', async () => {
const res = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [ { name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] } ]
});
if (res.canceled || res.filePaths.length === 0) return { canceled: true };
const filePath = res.filePaths[0];
const content = fs.readFileSync(filePath, 'utf-8');
return { canceled: false, filePath, content };
});
ipcMain.handle('file:saveAs', async (_e, payload) => {
const res = await dialog.showSaveDialog({
defaultPath: (payload && payload.suggestedName) || 'config.yaml',
filters: [ { name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] } ]
});
if (res.canceled || !res.filePath) return { canceled: true };
fs.writeFileSync(res.filePath, payload.content, 'utf-8');
return { canceled: false, filePath: res.filePath };
});
ipcMain.handle('file:saveToPath', async (_e, payload) => {
try {
fs.writeFileSync(payload.filePath, payload.content, 'utf-8');
return { ok: true };
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) };
}
});
ipcMain.handle('os:revealInFolder', async (_e, filePath) => {
try {
shell.showItemInFolder(filePath);
return { ok: true };
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) };
}
});

View File

@@ -0,0 +1,23 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
openFile: async () => ipcRenderer.invoke('file:open'),
saveAs: async (payload) => ipcRenderer.invoke('file:saveAs', payload),
saveToPath: async (payload) => ipcRenderer.invoke('file:saveToPath', payload),
revealInFolder: async (filePath) => ipcRenderer.invoke('os:revealInFolder', filePath),
getConfig: async (key) => ipcRenderer.invoke('config:get', key),
setConfig: async (key, value) => ipcRenderer.invoke('config:set', key, value),
getAllConfig: async () => ipcRenderer.invoke('config:getAll'),
setAllConfig: async (config) => ipcRenderer.invoke('config:setAll', config),
closeSettings: async () => ipcRenderer.invoke('settings:close'),
localKnock: async (payload) => ipcRenderer.invoke('knock:local', payload),
getNetworkInterfaces: async () => ipcRenderer.invoke('network:interfaces'),
testConnection: async (payload) => ipcRenderer.invoke('network:test-connection', payload)
});
// Пробрасываем конфигурацию в рендерер (безопасно)
contextBridge.exposeInMainWorld('config', {
apiBase: process.env.KNOCKER_DESKTOP_API_BASE || 'http://localhost:8080/api/v1'
});

View File

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

View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Knocker Desktop</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app">
<header>
<h1 style="font-size: 2.5rem; margin-bottom: 1rem">
Port Knocker - Desktop
</h1>
<div class="modes">
<label
><input type="radio" name="mode" value="inline" checked />
Inline</label
>
<label><input type="radio" name="mode" value="yaml" /> YAML</label>
<label><input type="radio" name="mode" value="form" /> Form</label>
</div>
</header>
<section id="constant-section" class="constant-mode-section">
<div class="row">
<label style="min-width: 100px">Api URL</label>
<input id="apiUrl" type="text" placeholder="Введите api url" />
</div>
<div class="row">
<label style="min-width: 100px">Пароль</label>
<input id="password" type="password" placeholder="Введите пароль" />
</div>
<div class="row">
<label style="min-width: 100px">Задержка</label>
<input id="delay" type="text" value="1s" />
</div>
</section>
<section id="inline-section" class="mode-section">
<div class="row">
<label style="min-width: 100px">Цели</label>
<input id="targets" type="text" value="tcp:127.0.0.1:22" />
</div>
<div class="row">
<label style="min-width: 100px">Шлюз: </label>
<input id="gateway" type="text" placeholder="опционально" />
</div>
<div class="row" style="margin-top: 1rem">
<label
><input id="verbose" type="checkbox" checked /> Подробный
вывод</label
>
<label
><input id="waitConnection" type="checkbox" /> Ждать
соединение</label
>
</div>
</section>
<section id="yaml-section" class="mode-section hidden">
<div class="toolbar">
<button id="openFile">Открыть файл…</button>
<button id="saveFile">Сохранить как…</button>
<input
id="serverFilePath"
type="text"
placeholder="server file path (path в YAML)"
/>
</div>
<textarea
id="configYAML"
placeholder="Вставьте YAML или откройте файл"
></textarea>
</section>
<section id="form-section" class="mode-section hidden">
<div id="targetsList"></div>
<button id="addTarget">Добавить цель</button>
</section>
<footer>
<div class="row" style="width: 100%; margin-top: 1rem">
<button style="width: 100%" id="execute">Выполнить</button>
</div>
<div class="row hidden" id="encrypt-decrypt-row" style="width: 100%; margin-top: 1rem">
<button style="width: 50%" id="encrypt">Зашифровать</button>
<button style="width: 50%" id="decrypt">Расшифровать</button>
</div>
<div class="row" style="width: 100%; margin-top: 1rem">
<span id="status"></span>
</div>
</footer>
</div>
<script src="../../node_modules/js-yaml/dist/js-yaml.min.js"></script>
<script src="./renderer.js"></script>
</body>
</html>

View File

@@ -0,0 +1,536 @@
(() => {
// Глобальные обработчики ошибок в renderer
window.addEventListener('error', (event) => {
console.error('Global error in renderer:', event.error);
});
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection in renderer:', event.reason);
});
let apiBase = window.config?.apiBase || "http://localhost:8080/api/v1";
const qs = (sel) => document.querySelector(sel);
const qsi = (sel) => document.querySelector(sel);
const qst = (sel) => document.querySelector(sel);
const yaml = window.jsyaml;
function setMode(mode) {
["inline", "yaml", "form"].forEach((m) => {
const el = qs(`#${m}-section`);
if (el) el.classList.toggle("hidden", m !== mode);
const encryptDecryptRow = qs("#encrypt-decrypt-row");
if (encryptDecryptRow) {
encryptDecryptRow.classList.toggle("hidden", mode !== "yaml");
}
});
}
function basicAuthHeader(password) {
const token = btoa(`knocker:${password}`);
return { Authorization: `Basic ${token}` };
}
function updateStatus(msg) {
const el = qs("#status");
if (el) {
el.textContent = msg;
setTimeout(() => {
el.textContent = "";
}, 5000); // Очищаем через 5 секунд
}
}
const targets = [{ protocol: "tcp", host: "127.0.0.1", port: 22, gateway: "" }];
function renderTargets() {
const list = qs("#targetsList");
if (!list) return;
list.innerHTML = "";
targets.forEach((t, idx) => {
const row = document.createElement("div");
row.className = "target-row";
row.innerHTML = `
<select data-k="protocol">
<option value="tcp" ${t.protocol === "tcp" ? "selected" : ""
}>tcp</option>
<option value="udp" ${t.protocol === "udp" ? "selected" : ""
}>udp</option>
</select>
<input type="text" data-k="host" value="${t.host}" />
<input type="number" data-k="port" value="${t.port}" />
<input type="text" data-k="gateway" value="${t.gateway || ""
}" placeholder="gateway (опц.)" />
<button class="remove" data-idx="${idx}">Удалить</button>`;
list.appendChild(row);
});
}
function serializeFormTargetsToInline() {
return targets
.map(
(t) =>
`${t.protocol}:${t.host}:${t.port}${t.gateway ? `:${t.gateway}` : ""}`
)
.join(";");
}
function convertInlineToYaml(targetsStr, delay, waitConnection) {
const entries = (targetsStr || "").split(";").filter(Boolean);
const config = {
targets: entries.map((e) => {
const parts = e.split(":");
const protocol = parts[0] || "tcp";
const host = parts[1] || "127.0.0.1";
const port = parseInt(parts[2] || "22", 10);
return {
protocol,
host,
ports: [port],
wait_connection: !!waitConnection,
};
}),
delay: delay || "1s",
};
return yaml.dump(config, { lineWidth: 120 });
}
function convertYamlToInline(yamlText) {
if (!yamlText.trim())
return {
targets: "tcp:127.0.0.1:22",
delay: "1s",
waitConnection: false,
};
const config = yaml.load(yamlText) || {};
const list = [];
(config.targets || []).forEach((t) => {
const protocol = t.protocol || "tcp";
const host = t.host || "127.0.0.1";
const ports = t.ports || [t.port] || [22];
(Array.isArray(ports) ? ports : [ports]).forEach((p) =>
list.push(`${protocol}:${host}:${p}`)
);
});
return {
targets: list.join(";"),
delay: config.delay || "1s",
waitConnection: !!config.targets?.[0]?.wait_connection,
};
}
function extractPathFromYaml(text) {
try {
const doc = yaml.load(text);
if (doc && typeof doc === "object" && typeof doc.path === "string")
return doc.path;
} catch { }
return "";
}
function patchYamlPath(text, newPath) {
try {
const doc = text.trim() ? yaml.load(text) : {};
if (doc && typeof doc === "object") {
doc.path = newPath || "";
return yaml.dump(doc, { lineWidth: 120 });
}
} catch { }
return text;
}
function isEncryptedYaml(text) {
return (text || "").trim().startsWith("ENCRYPTED:");
}
// Функция для обновления конфига из настроек
function updateConfigFromSettings() {
window.api.getConfig('apiBase')
.then((saved) => {
if (typeof saved === 'string' && saved.trim()) {
apiBase = saved;
if (qsi('#apiUrl')) {
qsi('#apiUrl').value = apiBase;
}
}
})
.catch(() => { });
window.api.getConfig('gateway')
.then((saved) => {
if (qsi('#gateway')) {
qsi('#gateway').value = saved || '';
}
})
.catch(() => { });
window.api.getConfig('inlineTargets')
.then((saved) => {
if (qsi('#targets')) {
qsi('#targets').value = saved || '';
}
})
.catch(() => { });
window.api.getConfig('delay')
.then((saved) => {
if (qsi('#delay')) {
qsi('#delay').value = saved || '';
}
})
.catch(() => { });
}
// событие возникающее когда загружается страница основная приложения
window.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('input[name="mode"]').forEach((r) => {
r.addEventListener("change", (e) => setMode(e?.target?.value || ''));
});
// Инициализация/восстановление apiBase из конфига
window.api.getConfig('apiBase')
.then((saved) => {
if (typeof saved === 'string' && saved.trim()) {
apiBase = saved;
}
})
.catch(() => { })
.finally(() => {
if (qsi('#apiUrl')) {
qsi('#apiUrl').value = apiBase;
}
});
// Сохранение apiBase при изменении поля
qsi('#apiUrl')?.addEventListener('change', async () => {
const val = qsi('#apiUrl').value.trim();
if (!val) return;
apiBase = val;
try { await window.api.setConfig('apiBase', val); } catch { }
updateStatus('API URL сохранён');
});
// Инициализация/восстановление gateway из конфига
window.api.getConfig('gateway')
.then((saved) => {
if (qsi('#gateway')) {
qsi('#gateway').value = saved || '';
}
})
.catch(() => { });
// Сохранение Gateway при изменении поля
qsi('#gateway')?.addEventListener('change', async () => {
const val = qsi('#gateway').value.trim();
try { await window.api.setConfig('gateway', val); } catch { }
updateStatus('Gateway сохранён');
});
// Инициализация/восстановление inlineTargets из конфига
window.api.getConfig('inlineTargets')
.then((saved) => {
if (qsi('#targets')) {
qsi('#targets').value = saved || '';
}
})
.catch(() => { });
// Сохранение inlineTargets при изменении поля
qsi('#targets')?.addEventListener('change', async () => {
const val = qsi('#targets').value.trim();
try { await window.api.setConfig('inlineTargets', val); } catch { }
updateStatus('inlineTargets сохранёны');
});
// Инициализация/восстановление delay из конфига
window.api.getConfig('delay')
.then((saved) => {
if (qsi('#delay')) {
qsi('#delay').value = saved || '';
}
})
.catch(() => { });
// Сохранение delay при изменении поля
qsi('#delay')?.addEventListener('change', async () => {
const val = qsi('#delay').value.trim();
try { await window.api.setConfig('delay', val); } catch { }
updateStatus('Задержка сохранёна');
});
qsi("#addTarget")?.addEventListener("click", () => {
targets.push({ protocol: "tcp", host: "127.0.0.1", port: 22 });
renderTargets();
});
qs("#targetsList")?.addEventListener("input", (e) => {
const row = e.target.closest(".target-row");
if (!row) return;
const idx = Array.from(row.parentElement.children).indexOf(row);
const key = e.target.getAttribute("data-k");
if (idx >= 0 && key) {
const val =
e.target.type === "number" ? Number(e.target.value) : e.target.value;
targets[idx][key] = val;
}
});
qs("#targetsList")?.addEventListener("click", (e) => {
if (!e.target.classList.contains("remove")) {
return;
}
const row = e.target.closest(".target-row");
const idx = Array.from(row.parentElement.children).indexOf(row);
if (idx >= 0) {
targets.splice(idx, 1);
renderTargets();
}
});
qs("#openFile")?.addEventListener("click", async () => {
const res = await window.api.openFile();
if (!(!res.canceled && res.content !== undefined)) {
return;
}
qst("#configYAML").value = res.content;
const p = extractPathFromYaml(res.content);
qsi("#serverFilePath").value = p || "";
updateStatus(`Открыт файл: ${res.filePath}`);
});
qs("#saveFile")?.addEventListener("click", async () => {
const content = qst("#configYAML").value;
const suggested = content.trim().startsWith("ENCRYPTED:")
? "config.encrypted"
: "config.yaml";
const res = await window.api.saveAs({
suggestedName: suggested,
content,
});
if (!res.canceled && res.filePath) {
updateStatus(`Сохранено: ${res.filePath}`);
await window.api.revealInFolder(res.filePath);
}
});
qsi("#serverFilePath")?.addEventListener("input", () => {
const newPath = qsi("#serverFilePath").value;
const current = qst("#configYAML").value;
qst("#configYAML").value = patchYamlPath(current, newPath);
});
qs("#execute")?.addEventListener("click", async () => {
updateStatus("Выполнение…");
const password = qsi("#password").value;
const mode = document.querySelector('input[name="mode"]:checked')?.value || '';
// Проверяем, нужно ли использовать локальное простукивание
const useLocalKnock = !apiBase || apiBase.trim() === '' || apiBase === 'internal';
if (useLocalKnock) {
// Локальное простукивание через Node.js
try {
let targets = [];
let delay = qsi("#delay").value || '1s';
const verbose = qsi("#verbose").checked;
if (mode === "inline") {
targets = qsi("#targets").value.split(';').filter(t => t.trim());
} else if (mode === "form") {
targets = [serializeFormTargetsToInline()];
} else if (mode === "yaml") {
// Для YAML режима извлекаем targets из YAML
const yamlContent = qst("#configYAML").value;
try {
const config = yaml.load(yamlContent);
if (config?.targets && Array.isArray(config.targets)) {
targets = config.targets.map(t => {
const protocol = t.protocol || 'tcp';
const host = t.host || '127.0.0.1';
const ports = t.ports || [t.port] || [22];
return ports.map(port => `${protocol}:${host}:${port}`);
}).flat();
delay = config.delay || delay;
}
} catch (e) {
updateStatus(`Ошибка парсинга YAML: ${e.message}`);
return;
}
}
if (targets.length === 0) {
updateStatus("Нет целей для простукивания");
return;
}
// Получаем gateway из конфигурации или поля
const gateway = qsi('#gateway')?.value?.trim() || '';
const result = await window.api.localKnock({
targets,
delay,
verbose,
gateway
});
if (result?.success) {
const summary = result.summary;
updateStatus(`Локальное простукивание завершено: ${summary.successful}/${summary.total} успешно`);
// Логируем детальные результаты в консоль
if (verbose) {
console.log('Local knock results:', result.results);
}
} else {
const errorMsg = result?.error || 'Неизвестная ошибка локального простукивания';
updateStatus(`Ошибка локального простукивания: ${errorMsg}`);
console.error('Local knock failed:', result);
}
} catch (e) {
updateStatus(`Ошибка: ${e?.message || String(e)}`);
}
return;
}
// API простукивание через HTTP
const body = {};
if (mode === "yaml") {
body.config_yaml = qst("#configYAML").value;
} else if (mode === "inline") {
body.targets = qsi("#targets").value;
body.delay = qsi("#delay").value;
body.verbose = qsi("#verbose").checked;
body.waitConnection = qsi("#waitConnection").checked;
body.gateway = qsi("#gateway").value;
} else {
body.targets = serializeFormTargetsToInline();
body.delay = qsi("#delay").value;
body.verbose = qsi("#verbose").checked;
body.waitConnection = qsi("#waitConnection").checked;
}
let result;
try {
result = await fetch(`${apiBase}/knock-actions/execute`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...basicAuthHeader(password),
},
body: JSON.stringify(body),
});
if (result?.ok) {
updateStatus("Успешно простучали через API...");
} else {
updateStatus(`Ошибка API: ${result.statusText}`);
}
} catch (e) {
updateStatus(`Ошибка: ${e?.message || String(e)}`);
}
});
qs("#encrypt")?.addEventListener("click", async () => {
const password = qsi("#password").value;
const content = qst("#configYAML").value;
const pathFromYaml = extractPathFromYaml(content);
if (!content.trim()) return;
const url = `${apiBase}/knock-actions/encrypt`;
const payload = { yaml: content };
try {
const r = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...basicAuthHeader(password),
},
body: JSON.stringify(payload),
});
const res = await r.json();
const encrypted = res?.encrypted || "";
qst("#configYAML").value = encrypted;
updateStatus("Зашифровано");
if (!pathFromYaml) {
await window.api.saveAs({
suggestedName: "config.encrypted",
content: encrypted,
});
}
} catch (e) {
updateStatus(`Ошибка: ${e?.message || String(e)}`);
}
});
qs("#decrypt")?.addEventListener("click", async () => {
const password = qsi("#password").value;
const content = qst("#configYAML").value;
if (!content.trim() || !isEncryptedYaml(content)) return;
try {
const r = await fetch(`${apiBase}/knock-actions/decrypt`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...basicAuthHeader(password),
},
body: JSON.stringify({ encrypted: content }),
});
const res = await r.json();
const plain = res?.yaml || "";
qst("#configYAML").value = plain;
const p = extractPathFromYaml(plain);
if (p) qsi("#serverFilePath").value = p;
updateStatus("Расшифровано");
} catch (e) {
updateStatus(`Ошибка: ${e?.message || String(e)}`);
}
});
renderTargets();
// Обновляем конфиг при фокусе окна (если настройки были изменены)
window.addEventListener('focus', updateConfigFromSettings);
// Диагностические функции
window.testNetworkInterfaces = async () => {
try {
const result = await window.api.getNetworkInterfaces();
if (result.success) {
console.log('Network interfaces:', result.interfaces);
updateStatus('Network interfaces logged to console');
} else {
updateStatus(`Error getting interfaces: ${result.error}`);
}
} catch (e) {
updateStatus(`Error: ${e.message}`);
}
};
window.testConnection = async () => {
try {
const gateway = qsi('#gateway')?.value?.trim();
if (!gateway) {
updateStatus('Please set gateway first');
return;
}
const result = await window.api.testConnection({
host: '192.168.89.1',
port: 2655,
localAddress: gateway
});
if (result.success) {
updateStatus(`Test connection successful: ${result.message}`);
console.log('Test connection result:', result);
} else {
updateStatus(`Test connection failed: ${result.error}`);
}
} catch (e) {
updateStatus(`Error: ${e.message}`);
}
};
// Добавляем диагностические кнопки в консоль
console.log('Diagnostic functions available:');
console.log('- window.testNetworkInterfaces() - Show network interfaces');
console.log('- window.testConnection() - Test connection with gateway');
});
})();

View File

@@ -0,0 +1,163 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Настройки - Knocker Desktop</title>
<style>
body {
margin: 0;
padding: 20px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #f5f5f5;
}
.container {
max-width: 100%;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: #2c3e50;
color: white;
padding: 15px 20px;
font-size: 18px;
font-weight: 600;
}
.content {
padding: 20px;
}
.field-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
textarea {
width: 100%;
height: 300px;
padding: 12px;
border: 2px solid #ddd;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
resize: vertical;
box-sizing: border-box;
}
textarea:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
.buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
}
button {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-secondary:hover {
background: #7f8c8d;
}
.status {
margin-top: 10px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
display: none;
}
.status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.help {
margin-top: 10px;
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
font-size: 12px;
color: #666;
line-height: 1.4;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
⚙️ Настройки приложения
</div>
<div class="content">
<div class="field-group">
<label for="configJson">Конфигурация (JSON формат):</label>
<textarea id="configJson" placeholder="Загрузка конфигурации..."></textarea>
</div>
<div class="help">
<strong>Доступные параметры:</strong><br>
<code>apiBase</code> - URL API сервера (например: "http://localhost:8080/api/v1")<br>
<code>gateway</code> - Шлюз по умолчанию<br>
<code>inlineTargets</code> - Inline цели (в формате "tcp:127.0.0.1:22")<br>
<code>delay</code> - Задержка (например: "1s")
</div>
<div class="buttons">
<button class="btn-secondary" id="cancelBtn">Вернуться</button>
<button class="btn-primary" id="saveBtn">Сохранить</button>
</div>
<div id="status" class="status"></div>
</div>
</div>
<script src="settings.js"></script>
</body>
</html>

View File

@@ -0,0 +1,149 @@
(() => {
const qs = (sel) => document.querySelector(sel);
const qst = (sel) => document.querySelector(sel);
function showStatus(message, type = 'success') {
const status = qs('#status');
status.textContent = message;
status.className = `status ${type}`;
status.style.display = 'block';
setTimeout(() => {
status.style.display = 'none';
}, 3000);
}
function validateJson(text) {
try {
JSON.parse(text);
return { valid: true };
} catch (e) {
return { valid: false, error: e.message };
}
}
function formatJson(obj) {
return JSON.stringify(obj, null, 2);
}
// Загрузка текущей конфигурации
async function loadConfig() {
try {
const config = await window.api.getAllConfig();
const jsonText = formatJson(config);
qst('#configJson').value = jsonText;
} catch (e) {
console.error('Failed to load config:', e);
showStatus('Ошибка загрузки конфигурации', 'error');
qst('#configJson').value = '{}';
}
}
// Сохранение конфигурации
async function saveConfig() {
const text = qst('#configJson').value.trim();
if (!text) {
showStatus('Конфигурация не может быть пустой', 'error');
return;
}
const validation = validateJson(text);
if (!validation.valid) {
showStatus(`Неверный JSON: ${validation.error}`, 'error');
return;
}
try {
const config = JSON.parse(text);
const result = await window.api.setAllConfig(config);
if (result.ok) {
showStatus('Конфигурация успешно сохранена');
// Обновляем конфиг в главном окне
setTimeout(() => {
if (window.opener) {
window.opener.location.reload();
}
}, 1000);
} else {
showStatus(`Ошибка сохранения: ${result.error}`, 'error');
}
} catch (e) {
console.error('Failed to save config:', e);
showStatus('Ошибка сохранения конфигурации', 'error');
}
}
// Закрытие окна через IPC
async function closeWindow() {
try {
const result = await window.api.closeSettings();
if (!result.ok) {
console.error('Failed to close settings window:', result.error);
}
} catch (e) {
console.error('Error closing settings window:', e);
}
}
// Обработчики событий
window.addEventListener('DOMContentLoaded', () => {
// Загружаем конфигурацию при открытии
loadConfig();
// Кнопка сохранения
qs('#saveBtn').addEventListener('click', saveConfig);
// Кнопка возврата
qs('#cancelBtn').addEventListener('click', () => closeWindow());
// Горячие клавиши
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
saveConfig();
} else if (e.key === 'w' || e.key === 'Escape') {
e.preventDefault();
closeWindow();
}
}
});
// Автосохранение при изменении (опционально)
let saveTimeout;
qst('#configJson').addEventListener('input', () => {
clearTimeout(saveTimeout);
// Можно добавить автосохранение через 5 секунд бездействия
// saveTimeout = setTimeout(() => {
// const validation = validateJson(qst('#configJson').value);
// if (validation.valid) {
// saveConfig();
// }
// }, 5000);
});
});
// Предотвращение случайного закрытия с несохраненными изменениями
let hasUnsavedChanges = false;
qst('#configJson').addEventListener('input', () => {
hasUnsavedChanges = true;
});
// Убираем beforeunload для Electron (не работает корректно)
// window.addEventListener('beforeunload', (e) => {
// if (hasUnsavedChanges) {
// e.preventDefault();
// e.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?';
// }
// });
// Сбрасываем флаг после сохранения
const originalSaveConfig = saveConfig;
saveConfig = async function() {
await originalSaveConfig();
hasUnsavedChanges = false;
};
})();

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

Binary file not shown.

View File

@@ -1,9 +1,8 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { BasicKnockPageComponent } from './basic-knock/basic-knock-page.component'; import { BasicKnockPageComponent } from './basic-knock/basic-knock-page.component';
import { FsaKnockPageComponent } from './fsa-knock/fsa-knock-page.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: BasicKnockPageComponent }, { path: '', component: BasicKnockPageComponent },
{ path: 'fsa', component: FsaKnockPageComponent },
{ path: '**', redirectTo: '' } { path: '**', redirectTo: '' }
]; ];

View File

@@ -15,7 +15,7 @@ import { KnockPageComponent } from '../knock/knock-page.component';
template: ` template: `
<div class="container"> <div class="container">
<!-- Встраиваем основной компонент в базовом режиме --> <!-- Встраиваем основной компонент в базовом режиме -->
<app-knock-page [enableFSA]="false" [canUseFSA]="canUseFSA"></app-knock-page> <app-knock-page></app-knock-page>
</div> </div>
<!-- Информационное модальное окно --> <!-- Информационное модальное окно -->

View File

@@ -1,132 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { CardModule } from 'primeng/card';
import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { KnockPageComponent } from '../knock/knock-page.component';
@Component({
selector: 'app-fsa-knock-page',
standalone: true,
imports: [
CommonModule, RouterModule, CardModule, ButtonModule, DialogModule, KnockPageComponent
],
template: `
<div class="container">
<div *ngIf="!isFSASupported" class="text-center">
<h3>File System Access API не поддерживается</h3>
<p>Эта функциональность требует браузер с поддержкой File System Access API:</p>
<ul class="text-left mt-3">
<li>Google Chrome 86+</li>
<li>Microsoft Edge 86+</li>
<li>Opera 72+</li>
</ul>
<p class="mt-3">Ваш браузер: <strong>{{ browserInfo }}</strong></p>
<button pButton
type="button"
label="Перейти к основной версии"
class="p-button-outlined mt-3"
routerLink="/">
</button>
</div>
<div *ngIf="isFSASupported">
<!-- Встраиваем основной компонент с поддержкой FSA -->
<app-knock-page [enableFSA]="true" [canUseFSA]="true"></app-knock-page>
</div>
</div>
<!-- Информационное модальное окно -->
<p-dialog header="🚀 Расширенная версия с File System Access"
[(visible)]="showInfoDialog"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="info-dialog">
<div class="dialog-content">
<p class="mb-3">
Эта версия поддерживает прямое редактирование файлов на диске.
Файлы будут автоматически перезаписываться после шифрования/дешифрования.
</p>
<div class="p-3 bg-green-50 border-round">
<p class="text-sm mb-2">
✅ <strong>Доступные возможности:</strong>
</p>
<ul class="text-sm mb-0">
<li>Прямое открытие файлов с диска</li>
<li>Автоматическое сохранение изменений</li>
<li>Перезапись зашифрованных файлов "на месте"</li>
<li>Быстрая работа без диалогов загрузки/скачивания</li>
</ul>
</div>
</div>
</p-dialog>
`,
styles: [`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
ul {
display: inline-block;
text-align: left;
}
.info-link {
color: #3b82f6;
cursor: pointer;
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.info-link:hover {
color: #1d4ed8;
text-decoration: underline;
}
.bg-green-50 {
background-color: #f0fdf4;
}
.dialog-content {
min-width: 450px;
}
`]
})
export class FsaKnockPageComponent {
isFSASupported = false;
browserInfo = '';
showInfoDialog = false;
constructor() {
this.checkFSASupport();
this.getBrowserInfo();
}
private checkFSASupport() {
const w = window as any;
this.isFSASupported = typeof w.showOpenFilePicker === 'function';
}
private getBrowserInfo() {
const ua = navigator.userAgent;
if (ua.includes('Chrome') && !ua.includes('Edg/')) {
this.browserInfo = 'Google Chrome';
} else if (ua.includes('Edg/')) {
this.browserInfo = 'Microsoft Edge';
} else if (ua.includes('Opera') || ua.includes('OPR/')) {
this.browserInfo = 'Opera';
} else if (ua.includes('Firefox')) {
this.browserInfo = 'Mozilla Firefox';
} else if (ua.includes('Safari') && !ua.includes('Chrome')) {
this.browserInfo = 'Safari';
} else {
this.browserInfo = 'Неизвестный браузер';
}
}
}

View File

@@ -1,76 +1,19 @@
<div class="container"> <div class="container">
<p-card [header]="cardHeader"> <p-card header="Port Knocker (Minimal UI)">
<ng-template pTemplate="header">
<div class="flex justify-content-between align-items-center">
<h1 style="margin-left: 1rem">Port Knocker</h1>
<!-- <div class="animated-title" [class.animating]="isAnimating">
<span *ngIf="cardHeader">{{ cardHeader }}</span>
</div> -->
<div class="flex gap-2">
<button
*ngIf="!enableFSA"
pButton
type="button"
label="📁 Info"
class="p-button-text p-button-sm"
(click)="showInfoDialog = true"
></button>
<button
*ngIf="canUseFSA && !enableFSA"
pButton
type="button"
label="🚀 FSA Version"
class="p-button-text p-button-sm"
routerLink="/fsa"
></button>
</div>
</div>
</ng-template>
<form [formGroup]="form" (ngSubmit)="execute()" class="p-fluid"> <form [formGroup]="form" (ngSubmit)="execute()" class="p-fluid">
<div class="grid"> <div class="grid">
<div class="col-12 md:col-6"> <div class="col-12">
<label>Password</label>
<p-password
formControlName="password"
[feedback]="false"
toggleMask
inputStyleClass="w-full"
placeholder="GO_KNOCKER_SERVE_PASS"
></p-password>
<div class="mt-1 text-sm" *ngIf="!form.value.password || wrongPass">
<span class="text-red-500" *ngIf="wrongPass">Invalid password</span>
<span class="text-600" *ngIf="!wrongPass && !form.value.password"
>Password is required</span
>
</div>
</div>
<div class="col-12 md:col-6">
<label>Mode</label>
<p-dropdown
formControlName="mode"
[options]="[
{ label: 'Inline', value: 'inline' },
{ label: 'YAML', value: 'yaml' }
]"
optionLabel="label"
optionValue="value"
class="w-full"
></p-dropdown>
</div>
<div class="col-12" *ngIf="form.value.mode === 'inline'">
<label>Targets</label> <label>Targets</label>
<input <input
pInputText pInputText
type="text" type="text"
formControlName="targets" formControlName="targets"
placeholder="tcp:host:port;udp:host:port" placeholder="tcp:host:port;udp:host:port;...;tcp:host:port"
class="w-full" class="w-full"
/> />
</div> </div>
<div class="col-12 md:col-4" *ngIf="form.value.mode === 'inline'"> <div class="col-12 md:col-6">
<label>Delay</label> <label>Delay</label>
<input <input
pInputText pInputText
@@ -81,83 +24,11 @@
/> />
</div> </div>
<div class="col-6 md:col-4 flex align-items-center gap-2"> <div class="col-12 md:col-6 flex align-items-center gap-2">
<p-checkbox formControlName="verbose" [binary]="true"></p-checkbox> <p-checkbox formControlName="waitConnection" [binary]="true"></p-checkbox>
<label class="checkbox-label">Verbose</label>
</div>
<div class="col-6 md:col-4 flex align-items-center gap-2">
<p-checkbox
formControlName="waitConnection"
[binary]="true"
></p-checkbox>
<label class="checkbox-label">Wait connection</label> <label class="checkbox-label">Wait connection</label>
</div> </div>
<div class="col-12">
<label>Gateway</label>
<input
pInputText
type="text"
formControlName="gateway"
placeholder="optional local ip:port"
class="w-full"
/>
</div>
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
<label>YAML</label>
<textarea
pInputTextarea
formControlName="configYAML"
rows="12"
placeholder="paste YAML or ENCRYPTED:"
class="w-full"
></textarea>
</div>
<!-- File controls directly under YAML -->
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
<div class="flex flex-wrap gap-2 align-items-center">
<!-- FSA version -->
<button
*ngIf="enableFSA"
pButton
type="button"
label="Open File (with write access)"
(click)="openFileWithWriteAccess()"
class="p-button-outlined"
></button>
<span
*ngIf="enableFSA && selectedFileName"
class="text-sm text-600"
>{{ selectedFileName }}</span
>
<!-- Basic version -->
<p-fileUpload
*ngIf="!enableFSA"
mode="basic"
name="file"
chooseLabel="Choose File"
(onSelect)="onFileUpload($event)"
[customUpload]="true"
[auto]="false"
accept=".yaml,.yml,.txt,.encrypted"
[maxFileSize]="1048576"
></p-fileUpload>
<input
*ngIf="!enableFSA && !isYamlEncrypted()"
pInputText
type="text"
class="w-full md:w-6"
placeholder="Server file path (optional)"
formControlName="serverFilePath"
/>
</div>
</div>
<!-- Row 1: Execute full width -->
<div class="col-12"> <div class="col-12">
<button <button
pButton pButton
@@ -165,58 +36,7 @@
label="Execute" label="Execute"
class="w-full" class="w-full"
[loading]="executing" [loading]="executing"
[disabled]="executing || !form.value.password || wrongPass" [disabled]="executing || form.invalid"
[ngClass]="{ 'p-button-danger': !form.value.password || wrongPass }"
></button>
</div>
<!-- Row 2: Encrypt / Decrypt half/half on desktop; stacked on mobile (только для YAML режима) -->
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<button
pButton
type="button"
label="Encrypt"
(click)="encrypt()"
class="p-button-secondary w-full"
[disabled]="
executing ||
!form.value.password ||
wrongPass ||
isYamlEncrypted()
"
></button>
</div>
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<button
pButton
type="button"
label="Decrypt"
(click)="decrypt()"
class="p-button-secondary w-full"
[disabled]="
executing ||
!form.value.password ||
wrongPass ||
!isYamlEncrypted()
"
></button>
</div>
<!-- Row 3: Download actions half/half on desktop; stacked on mobile (только для YAML режима) -->
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<button
pButton
type="button"
label="Download YAML"
(click)="downloadYaml()"
class="p-button-text w-full"
></button>
</div>
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<button
pButton
type="button"
label="Download Result"
(click)="downloadResult()"
class="p-button-text w-full"
></button> ></button>
</div> </div>
</div> </div>
@@ -231,87 +51,15 @@
[mode]="executing ? 'indeterminate' : 'determinate'" [mode]="executing ? 'indeterminate' : 'determinate'"
></p-progressBar> ></p-progressBar>
<div class="mt-2 text-600" *ngIf="executing"> <div class="mt-2 text-600" *ngIf="executing">
Elapsed: {{ elapsedMs / 1000 | number : "1.1-1" }}s Elapsed: {{ elapsedMs / 1000 | number : '1.1-1' }}s
</div> </div>
<div class="mt-2 text-600" *ngIf="!executing && elapsedMs > 0"> <div class="mt-2 text-600" *ngIf="!executing && result">
Last run: {{ elapsedMs / 1000 | number : "1.1-1" }}s {{ result }}
<span *ngIf="lastRunTime" class="ml-2 text-500"> </div>
({{ lastRunTime | date : "short" }}) <div class="mt-2 text-red-600" *ngIf="!executing && error">
</span> {{ error }}
</div> </div>
</div> </div>
</div> </div>
</p-card> </p-card>
</div> </div>
<!-- Модальное окно с результатом выполнения -->
<p-dialog
header="Результат выполнения"
[(visible)]="showResultDialog"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="result-dialog"
>
<div class="dialog-content">
<div *ngIf="result" class="mb-3">
<h4 class="text-green-600 mb-2">✅ Успешно выполнено</h4>
<pre class="bg-gray-50 p-3 border-round text-sm">{{ result }}</pre>
</div>
<div *ngIf="error" class="mb-3">
<h4 class="text-red-600 mb-2">❌ Ошибка</h4>
<pre class="bg-red-50 p-3 border-round text-sm text-red-700">{{
error
}}</pre>
</div>
<div *ngIf="lastRunTime" class="text-sm text-600">
Время выполнения: {{ elapsedMs / 1000 | number : "1.1-1" }}s
<br />
Завершено: {{ lastRunTime | date : "short" }}
</div>
</div>
<ng-template pTemplate="footer">
<button
pButton
type="button"
label="OK"
class="p-button-primary"
(click)="showResultDialog = false"
></button>
</ng-template>
</p-dialog>
<!-- Информационное модальное окно -->
<p-dialog
header="📁 Базовая версия"
[(visible)]="showInfoDialog"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="info-dialog"
>
<div class="dialog-content">
<p class="mb-3">
Эта версия работает в любом браузере, но файлы загружаются/скачиваются
через стандартные диалоги браузера.
</p>
<div *ngIf="canUseFSA" class="p-3 bg-blue-50 border-round">
<p class="text-sm mb-2">
💡 <strong>Доступна расширенная версия!</strong>
</p>
<p class="text-sm mb-3">
Ваш браузер поддерживает прямое редактирование файлов на диске.
</p>
<button
pButton
type="button"
label="Перейти к расширенной версии"
class="p-button-success p-button-sm"
routerLink="/fsa"
(click)="showInfoDialog = false"
></button>
</div>
</div>
</p-dialog>

View File

@@ -1,384 +1,81 @@
import { Component, inject, Input } from '@angular/core'; import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { InputTextModule } from 'primeng/inputtext'; import { InputTextModule } from 'primeng/inputtext';
import { PasswordModule } from 'primeng/password';
import { DropdownModule } from 'primeng/dropdown';
import { CheckboxModule } from 'primeng/checkbox'; import { CheckboxModule } from 'primeng/checkbox';
import { InputTextareaModule } from 'primeng/inputtextarea';
import { ButtonModule } from 'primeng/button'; import { ButtonModule } from 'primeng/button';
import { CardModule } from 'primeng/card'; import { CardModule } from 'primeng/card';
import { DividerModule } from 'primeng/divider'; import { DividerModule } from 'primeng/divider';
import { FileUploadModule } from 'primeng/fileupload';
import { ProgressBarModule } from 'primeng/progressbar'; import { ProgressBarModule } from 'primeng/progressbar';
import { DialogModule } from 'primeng/dialog';
import * as yaml from 'js-yaml';
import { environment } from '../../environments/environment';
@Component({ @Component({
selector: 'app-knock-page', selector: 'app-knock-page',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, RouterModule, ReactiveFormsModule, FormsModule, CommonModule,
InputTextModule, PasswordModule, DropdownModule, CheckboxModule, RouterModule,
InputTextareaModule, ButtonModule, CardModule, DividerModule, ReactiveFormsModule,
FileUploadModule, ProgressBarModule, DialogModule InputTextModule,
CheckboxModule,
ButtonModule,
CardModule,
DividerModule,
ProgressBarModule,
], ],
templateUrl: './knock-page.component.html', templateUrl: './knock-page.component.html',
styleUrls: ['./knock-page.component.scss'] styleUrls: ['./knock-page.component.scss'],
}) })
export class KnockPageComponent { export class KnockPageComponent {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
@Input() enableFSA = false; // Включает File System Access API функциональность
@Input() canUseFSA = false; // Доступна ли FSA версия
// cardHeader = 'Port Knocker GUI';
cardHeader = '';
animatedTitle = 'Knock Knock Knock on the heaven\'s door ...';
showInfoDialog = false;
isAnimating = false;
showResultDialog = false;
executing = false; executing = false;
elapsedMs = 0;
private timerId: any = null; private timerId: any = null;
private startTs = 0; private startTs = 0;
elapsedMs = 0;
lastRunTime: Date | null = null;
wrongPass = false;
selectedFileName: string | null = null;
private fileHandle: any = null; // FileSystemFileHandle
private isSyncing = false;
result: string | null = null; result: string | null = null;
error: string | null = null; error: string | null = null;
form = this.fb.group({ form = this.fb.group({
password: ['', Validators.required], targets: ['tcp:127.0.0.1:22', Validators.required],
mode: ['inline', Validators.required], delay: ['1s', Validators.required],
targets: ['tcp:127.0.0.1:22'],
delay: ['1s'],
verbose: [true],
waitConnection: [false], waitConnection: [false],
gateway: [''],
configYAML: [''],
serverFilePath: ['']
}); });
constructor() {
// Загружаем сохраненное состояние из localStorage
this.loadStateFromLocalStorage();
// Запускаем анимацию заголовка
this.startTitleAnimation();
// Сбрасываем индикатор неверного пароля при изменении поля
this.form.get('password')?.valueChanges.subscribe(() => {
this.wrongPass = false;
});
// React on YAML text changes: extract path and sync to serverFilePath
this.form.get('configYAML')?.valueChanges.subscribe((val) => {
if (this.isSyncing) return;
if (this.isInlineMode() || this.isYamlEncrypted()) return;
try {
const p = this.extractPathFromYaml(String(val ?? ''));
const currentPath = this.form.value.serverFilePath || '';
if (p && p !== currentPath) {
this.isSyncing = true;
this.form.patchValue({ serverFilePath: p });
this.isSyncing = false;
}
} catch {}
});
// React on serverFilePath changes: update YAML path field
this.form.get('serverFilePath')?.valueChanges.subscribe((newPath) => {
if (this.isSyncing) return;
this.onServerPathChange(newPath || '');
});
// Подписка на изменение режима для автоматического преобразования
this.setupModeConversion();
// Подписки на изменения полей для автосохранения в localStorage
this.setupAutoSave();
// File System Access API detection (для обратной совместимости)
// Логика FSA теперь находится в отдельных компонентах
}
private authHeader(pass: string) {
// Basic auth с пользователем "knocker"
const token = btoa(`knocker:${pass}`);
return { Authorization: `Basic ${token}` };
}
execute() { execute() {
this.error = null; this.error = null;
this.result = null; this.result = null;
this.wrongPass = false;
if (this.form.invalid) return; if (this.form.invalid) return;
const v = this.form.value; const v = this.form.value;
const body: any = { const body: any = {
targets: v.targets, targets: v.targets,
delay: v.delay, delay: v.delay,
verbose: v.verbose,
waitConnection: v.waitConnection, waitConnection: v.waitConnection,
gateway: v.gateway, verbose: true,
}; };
if (v.mode === 'yaml') {
body.config_yaml = v.configYAML;
delete body.targets;
delete body.delay;
}
this.executing = true; this.executing = true;
this.startTimer(); this.startTimer();
this.http.post('/api/v1/knock-actions/execute', body, {
headers: this.authHeader(v.password || '')
}).subscribe({ this.http.post('/api/v1/knock-actions/execute', body).subscribe({
next: () => { next: () => {
this.executing = false; this.executing = false;
this.stopTimer(); this.stopTimer();
this.lastRunTime = new Date(); this.result = `Done in ${(this.elapsedMs / 1000).toFixed(2)}s`;
this.result = `Done in ${(this.elapsedMs/1000).toFixed(2)}s`;
this.showResultDialog = true;
}, },
error: (e: HttpErrorResponse) => { error: (e: HttpErrorResponse) => {
this.executing = false; this.executing = false;
this.stopTimer(); this.stopTimer();
if (e.status === 401) { this.error = e.error?.error || e.message;
this.wrongPass = true;
}
this.error = (e.error?.error) || e.message;
this.showResultDialog = true;
}
});
}
encrypt() {
this.error = null;
this.result = null;
const v = this.form.value;
if (this.isInlineMode() || this.isYamlEncrypted() || !v.password || this.wrongPass) {
return;
}
// Проверяем есть ли path в YAML самом
const pathFromYaml = this.getPathFromYaml(v.configYAML || '');
const serverFilePath = (this.form.value.serverFilePath || '').trim();
let url: string;
let body: any;
if (pathFromYaml) {
// Если path в YAML - используем /encrypt, сервер сам найдет path в YAML
url = '/api/v1/knock-actions/encrypt';
body = { yaml: v.configYAML };
} else if (serverFilePath) {
// Если path только в serverFilePath - используем /encrypt-file
url = '/api/v1/knock-actions/encrypt-file';
body = { path: serverFilePath };
} else {
// Нет пути - обычное шифрование содержимого
url = '/api/v1/knock-actions/encrypt';
body = { yaml: v.configYAML };
}
this.http.post(url, body, {
headers: this.authHeader(v.password || '')
}).subscribe({
next: async (res: any) => {
const encrypted: string = res.encrypted || '';
// Всегда обновляем YAML поле зашифрованным содержимым
this.form.patchValue({ configYAML: encrypted });
if (pathFromYaml) {
this.result = `Encrypted (YAML path: ${pathFromYaml})`;
// НЕ сохраняем файл клиентом - сервер уже записал по path из YAML
} else if (serverFilePath) {
this.result = `Encrypted (server path: ${serverFilePath})`;
// НЕ сохраняем файл клиентом - сервер записал по serverFilePath
} else {
this.result = 'Encrypted';
// Только сохраняем в файл если НЕТ серверного пути
await this.saveBackToFileIfPossible(encrypted, this.selectedFileName);
}
}, },
error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message
}); });
} }
decrypt() {
this.error = null;
this.result = null;
const v = this.form.value;
if (this.isInlineMode() || !this.isYamlEncrypted() || !v.password || this.wrongPass) {
return;
}
// Для зашифрованного YAML поле serverFilePath недоступно - используем только decrypt
const url = '/api/v1/knock-actions/decrypt';
const body = { encrypted: v.configYAML as string };
this.http.post(url, body, {
headers: this.authHeader(v.password || '')
}).subscribe({
next: async (res: any) => {
const plain: string = res.yaml || '';
this.form.patchValue({ configYAML: plain });
this.result = 'Decrypted';
// Извлекаем path из расшифрованного YAML и обновляем serverFilePath
const pathFromDecrypted = this.getPathFromYaml(plain);
if (pathFromDecrypted) {
this.isSyncing = true;
this.form.patchValue({ serverFilePath: pathFromDecrypted });
this.isSyncing = false;
this.result += ` (found path: ${pathFromDecrypted})`;
}
// НЕ делаем download - сервер уже обработал файл согласно path в YAML
},
error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message
});
}
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
this.selectedFileName = file.name;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result || '');
this.form.patchValue({ configYAML: text, mode: 'yaml' });
// Sync path from YAML into serverFilePath
const p = this.extractPathFromYaml(text);
if (p) {
this.isSyncing = true;
this.form.patchValue({ serverFilePath: p });
this.isSyncing = false;
}
};
reader.readAsText(file);
this.fileHandle = null; // обычная загрузка не даёт handle на запись
}
downloadYaml() {
const yaml = this.form.value.configYAML || '';
this.triggerDownload('config.yaml', yaml);
}
downloadResult() {
const content = this.result || this.form.value.configYAML || '';
const name = (content || '').startsWith('ENCRYPTED:') ? 'config.encrypted' : 'config.yaml';
this.triggerDownload(name, content);
}
onFileUpload(event: any) {
const files: File[] = event?.files || event?.currentFiles || [];
if (!files.length) return;
const file = files[0];
this.selectedFileName = file.name;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result || '');
this.form.patchValue({ configYAML: text, mode: 'yaml' });
const p = this.extractPathFromYaml(text);
if (!p) {
return;
}
this.isSyncing = true;
this.form.patchValue({ serverFilePath: p });
this.isSyncing = false;
};
reader.readAsText(file);
this.fileHandle = null;
}
async openFileWithWriteAccess() {
try {
const w: any = window as any;
if (!w || typeof w.showOpenFilePicker !== 'function') {
this.error = 'File System Access API is not supported by this browser.';
return;
}
const [handle] = await w.showOpenFilePicker({
types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }]
});
this.fileHandle = handle;
const file = await handle.getFile();
this.selectedFileName = file.name;
const text = await file.text();
this.form.patchValue({ configYAML: text, mode: 'yaml' });
const p = this.extractPathFromYaml(text);
if (p) {
this.isSyncing = true;
this.form.patchValue({ serverFilePath: p });
this.isSyncing = false;
}
this.result = `Opened: ${file.name}`;
this.error = null;
} catch (e: any) {
// user cancelled or error
}
}
// Helpers for UI state
isInlineMode(): boolean {
return (this.form.value.mode === 'inline');
}
isYamlEncrypted(): boolean {
const s = (this.form.value.configYAML || '').toString().trim();
return s.startsWith('ENCRYPTED:');
}
private triggerDownload(filename: string, text: string) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
private async saveBackToFileIfPossible(content: string, filename: string | null) {
try {
const w: any = window as any;
if (this.fileHandle && typeof this.fileHandle.createWritable === 'function') {
const writable = await this.fileHandle.createWritable();
await writable.write(content);
await writable.close();
return;
}
if (w && typeof w.showSaveFilePicker === 'function') {
const handle = await w.showSaveFilePicker({
suggestedName: filename || 'config.yaml',
types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }]
});
const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
return;
} else if (filename) {
this.triggerDownload(filename, content);
} else {
this.triggerDownload('config.yaml', content);
}
} catch {
if (filename) {
this.triggerDownload(filename, content);
} else {
this.triggerDownload('config.yaml', content);
}
}
}
private startTimer() { private startTimer() {
this.elapsedMs = 0; this.elapsedMs = 0;
this.startTs = Date.now(); this.startTs = Date.now();
@@ -401,380 +98,4 @@ export class KnockPageComponent {
this.timerId = null; this.timerId = null;
} }
} }
// YAML path helpers
private getPathFromYaml(text: string): string {
return this.extractPathFromYaml(text);
}
private 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 '';
}
onServerPathChange(newPath: string) {
if (this.isInlineMode() || this.isYamlEncrypted()) return;
environment.log('onServerPathChange', newPath);
const current = String(this.form.value.configYAML || '');
try {
const doc: any = current.trim() ? yaml.load(current) : {};
if (!doc || typeof doc !== 'object') return;
(doc as any).path = newPath || '';
this.isSyncing = true;
this.form.patchValue({ configYAML: yaml.dump(doc, { lineWidth: 120 }) }, { emitEvent: true });
this.isSyncing = false;
} catch {}
}
// LocalStorage functionality
private readonly STORAGE_KEY = 'knocker-ui-state';
private loadStateFromLocalStorage() {
try {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (!saved) return;
const state = JSON.parse(saved);
environment.log('Loading saved state:', state);
// Применяем сохраненные значения к форме
const patchData: any = {};
if (state.mode !== undefined) patchData.mode = state.mode;
if (state.targets !== undefined) patchData.targets = state.targets;
if (state.delay !== undefined) patchData.delay = state.delay;
if (state.verbose !== undefined) patchData.verbose = state.verbose;
if (state.waitConnection !== undefined) patchData.waitConnection = state.waitConnection;
if (state.configYAML !== undefined) patchData.configYAML = state.configYAML;
if (Object.keys(patchData).length > 0) {
this.form.patchValue(patchData);
// Если загружен YAML, извлекаем path и устанавливаем в serverFilePath
if (state.configYAML) {
const pathFromYaml = this.getPathFromYaml(state.configYAML);
if (pathFromYaml) {
this.isSyncing = true;
this.form.patchValue({ serverFilePath: pathFromYaml });
this.isSyncing = false;
environment.log('Extracted path from loaded YAML:', pathFromYaml);
}
}
}
} catch (e) {
console.warn('Failed to load state from localStorage:', e);
}
}
private saveStateToLocalStorage() {
try {
const formValue = this.form.value;
const state = {
mode: formValue.mode,
targets: formValue.targets,
delay: formValue.delay,
verbose: formValue.verbose,
waitConnection: formValue.waitConnection,
configYAML: formValue.configYAML
};
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
environment.log('State saved to localStorage:', state);
} catch (e) {
console.warn('Failed to save state to localStorage:', e);
}
}
private setupAutoSave() {
// Подписываемся на изменения нужных полей
const fieldsToWatch = ['mode', 'targets', 'delay', 'verbose', 'waitConnection', 'configYAML'];
fieldsToWatch.forEach(fieldName => {
this.form.get(fieldName)?.valueChanges.subscribe(() => {
// Небольшая задержка, чтобы не сохранять на каждое нажатие клавиши
setTimeout(() => this.saveStateToLocalStorage(), 300);
});
});
}
// Автоматическое преобразование между режимами
private setupModeConversion() {
let previousMode = this.form.value.mode;
this.form.get('mode')?.valueChanges.subscribe((newMode) => {
if (this.isSyncing) return;
environment.log(`Mode changed from ${previousMode} to ${newMode}`);
if (previousMode === 'inline' && newMode === 'yaml') {
this.handleModeChangeToYaml();
} else if (previousMode === 'yaml' && newMode === 'inline') {
this.handleModeChangeToInline();
}
previousMode = newMode;
});
}
private handleModeChangeToYaml() {
try {
// Проверяем есть ли сохраненный YAML в localStorage
const savedYaml = this.getSavedConfigYAML();
if (savedYaml?.trim()) {
// Используем сохраненный YAML
environment.log('Using saved YAML from localStorage');
this.isSyncing = true;
this.form.patchValue({ configYAML: savedYaml });
this.isSyncing = false;
} else {
// Конвертируем из inline
environment.log('Converting inline to YAML');
this.convertInlineToYaml();
}
// После установки YAML (из localStorage или конвертации) извлекаем path
setTimeout(() => this.extractAndSetServerPath(), 100);
} catch (e) {
console.warn('Failed to handle mode change to YAML:', e);
}
}
private handleModeChangeToInline() {
try {
// Проверяем есть ли сохраненные inline значения в localStorage
const savedTargets = this.getSavedTargets();
if (savedTargets && savedTargets.trim()) {
// Используем сохраненные inline значения
environment.log('Using saved inline targets from localStorage');
this.isSyncing = true;
this.form.patchValue({ targets: savedTargets });
this.isSyncing = false;
} else {
// Конвертируем из YAML
environment.log('Converting YAML to inline');
this.convertYamlToInline();
}
} catch (e) {
console.warn('Failed to handle mode change to inline:', e);
}
}
private getSavedConfigYAML(): string | null {
try {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (!saved) return null;
const state = JSON.parse(saved);
return state.configYAML || null;
} catch {
return null;
}
}
private getSavedTargets(): string | null {
try {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (!saved) return null;
const state = JSON.parse(saved);
return state.targets || null;
} catch {
return null;
}
}
private extractAndSetServerPath() {
try {
const yamlContent = this.form.value.configYAML || '';
if (!yamlContent.trim()) return;
const config: any = yaml.load(yamlContent);
if (config && typeof config === 'object' && config.path) {
environment.log('Extracted path from YAML:', config.path);
this.isSyncing = true;
this.form.patchValue({ serverFilePath: config.path });
this.isSyncing = false;
}
} catch (e) {
console.warn('Failed to extract path from YAML:', e);
}
}
private convertInlineToYaml() {
try {
const formValue = this.form.value;
const targets = (formValue.targets || '').split(';').filter(t => t.trim());
// Создаем YAML конфигурацию из inline параметров
const config: any = {
targets: targets.map(target => {
const [protocol, address] = target.trim().split(':');
const [host, port] = address ? address.split(':') : ['', ''];
return {
protocol: protocol || 'tcp',
host: host || '127.0.0.1',
ports: [parseInt(port) || 22],
wait_connection: formValue.waitConnection || false
};
}),
delay: formValue.delay || '1s'
};
const yamlString = yaml.dump(config, { lineWidth: 120 });
this.isSyncing = true;
this.form.patchValue({ configYAML: yamlString });
this.isSyncing = false;
environment.log('Converted inline to YAML:', config);
} catch (e) {
console.warn('Failed to convert inline to YAML:', e);
}
}
private convertYamlToInline() {
try {
const yamlContent = this.form.value.configYAML || '';
if (!yamlContent.trim()) {
// Если YAML пустой, используем значения по умолчанию
this.isSyncing = true;
this.form.patchValue({
targets: 'tcp:127.0.0.1:22',
delay: '1s',
waitConnection: false
});
this.isSyncing = false;
return;
}
const config: any = yaml.load(yamlContent);
if (!config || !config.targets || !Array.isArray(config.targets)) {
console.warn('Invalid YAML structure for conversion');
return;
}
// Конвертируем targets в строку - создаем отдельную строку для каждого порта
const targetStrings: string[] = [];
config.targets.forEach((target: any) => {
const protocol = target.protocol || 'tcp';
const host = target.host || '127.0.0.1';
// Поддерживаем как ports (массив), так и port (единственное число) для обратной совместимости
const ports = target.ports || [target.port] || [22];
if (Array.isArray(ports)) {
// Создаем отдельную строку для каждого порта
ports.forEach(port => {
targetStrings.push(`${protocol}:${host}:${port}`);
});
} else {
targetStrings.push(`${protocol}:${host}:${ports}`);
}
});
const targetsString = targetStrings.join(';');
const delay = config.delay || '1s';
// Берем wait_connection из первой цели (если есть)
const waitConnection = config.targets[0]?.wait_connection || false;
this.isSyncing = true;
this.form.patchValue({
targets: targetsString,
delay: delay,
waitConnection: waitConnection
});
this.isSyncing = false;
environment.log('Converted YAML to inline:', { targets: targetsString, delay, waitConnection });
} catch (e) {
console.warn('Failed to convert YAML to inline:', e);
}
}
// Публичный метод для очистки сохраненного состояния (опционально)
clearSavedState() {
try {
localStorage.removeItem(this.STORAGE_KEY);
environment.log('Saved state cleared from localStorage');
// Сбрасываем форму к значениям по умолчанию
this.form.patchValue({
mode: 'inline',
targets: 'tcp:127.0.0.1:22',
delay: '1s',
verbose: true,
waitConnection: false,
configYAML: ''
});
} catch (e) {
console.warn('Failed to clear saved state:', e);
}
}
// Анимация заголовка
private startTitleAnimation() {
if (this.isAnimating) return;
this.isAnimating = true;
// Первая анимация: по буквам
this.animateByLetters();
}
private animateByLetters() {
let currentIndex = 0;
const letters = this.animatedTitle.split('');
const interval = setInterval(() => {
if (currentIndex < letters.length) {
this.cardHeader = letters.slice(0, currentIndex + 1).join('');
currentIndex++;
} else {
clearInterval(interval);
// Ждем 2 секунды и начинаем исчезать
setTimeout(() => {
this.fadeOutTitle();
}, 2000);
}
}, 100); // 100ms между буквами
}
private fadeOutTitle() {
let opacity = 1;
const fadeInterval = setInterval(() => {
opacity -= 0.1;
if (opacity <= 0) {
clearInterval(fadeInterval);
this.cardHeader = '';
// Ждем 1 секунду и начинаем анимацию по словам
setTimeout(() => {
this.animateByWords();
}, 1000);
}
}, 50);
}
private animateByWords() {
const words = this.animatedTitle.split(' ');
let currentIndex = 0;
const interval = setInterval(() => {
if (currentIndex < words.length) {
this.cardHeader = words.slice(0, currentIndex + 1).join(' ');
currentIndex++;
} else {
clearInterval(interval);
this.isAnimating = false;
}
}, 300); // 300ms между словами
}
} }