init: media-center v2

Rewrite of ESH-Media v1 with separated main/renderer/shared architecture
(vite-plugin-electron, React 18, react-router-dom). Includes NeDB storage,
electron-store config, proxy manager with FoxyProxy/uBlock extensions,
custom server-checked updater, NSIS installer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 23:49:43 +03:00
commit ecb5e7e49f
52 changed files with 11718 additions and 0 deletions

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules/
# Build output
dist/
release/
out/
# Electron
*.log
*.pid
*.seed
.electron/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
# Database
*.db
data/
# Cache
.cache/

235
QUICKSTART.md Normal file
View File

@@ -0,0 +1,235 @@
# Quick Start Guide
Быстрый старт для Media Center v2
## 1. Установка
```bash
# Клонировать или перейти в директорию проекта
cd media_center_v2
# Установить зависимости
npm install
```
## 2. Запуск в режиме разработки
```bash
npm run dev
```
Это запустит приложение в режиме разработки с hot reload.
## 3. Первый запуск
После запуска приложения:
1. **Главная страница** откроется автоматически с карточками сайтов
2. **Прокси** запустится автоматически (если настроен)
3. **Попробуйте поиск**: введите название фильма в поисковую строку
## 4. Тестирование поиска
1. В верхней части окна введите "Стражи галактики"
2. Нажмите "Найти"
3. Дождитесь результатов со всех сайтов
4. Кликните на понравившийся результат
## 5. Сборка установщика
```bash
# Для Windows
npm run package:win
# Для macOS
npm run package:mac
# Для Linux
npm run package:linux
```
Установщик будет в папке `release/`.
## Структура файлов
### Важные файлы для начала:
- `config/sites.json` - настройки сайтов
- `search-scripts/` - скрипты поиска для каждого сайта
- `src/shared/types.ts` - TypeScript типы
- `src/shared/constants.ts` - константы (URL сервера, порты и т.д.)
### Изменение настроек:
**Сервер конфигураций:**
```typescript
// src/shared/constants.ts
export const DEFAULT_CONFIG_SERVER_URL = 'https://your-server.com/api';
```
**Порт прокси:**
```typescript
// src/shared/constants.ts
export const DEFAULT_PROXY_PORT = 10808;
```
**Путь к InvisibleManXRay:**
```typescript
// src/shared/constants.ts
export const INVISIBLE_MAN_CLI_PATH = 'invisibleManXRay';
```
## Добавление нового сайта
### Шаг 1: Создать скрипт поиска
Скопируйте `search-scripts/SCRIPT_TEMPLATE.js``search-scripts/mysite.js`
```javascript
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
const response = await axios.get(`${siteUrl}/search?q=${query}`);
const $ = cheerio.load(response.data);
const results = [];
$('.movie').each((i, el) => {
results.push({
name: $(el).find('.title').text(),
url: siteUrl + $(el).find('a').attr('href'),
image: $(el).find('img').attr('src')
});
});
return results;
}
```
### Шаг 2: Добавить в конфигурацию
Отредактируйте `config/sites.json`:
```json
{
"id": "mysite",
"name": "My Site",
"url": "https://mysite.com",
"logo": "https://mysite.com/favicon.ico",
"enabled": true,
"useProxy": false,
"searchScript": "mysite.js"
}
```
### Шаг 3: Перезапустить приложение
```bash
npm run dev
```
## Отладка
### Открыть DevTools
Нажмите `F12` в приложении
### Проверить логи
**Main process (терминал):**
```
[Proxy STDOUT]: ...
Found 5 results from Kinogo
```
**Renderer process (DevTools Console):**
```javascript
console.log('Search results:', results);
```
### Проверить ошибки скриптов
После выполнения поиска проверьте консоль на наличие сообщений вида:
```
Error searching Kinogo: timeout
```
## Частые проблемы
### 1. Прокси не запускается
**Проблема:** `Proxy failed to start`
**Решение:**
- Убедитесь, что InvisibleManXRay установлен
- Проверьте путь в `src/shared/constants.ts`
- Запустите вручную в терминале: `invisibleManXRay run`
### 2. Поиск не возвращает результаты
**Проблема:** `No results found`
**Решение:**
- Проверьте доступность сайта в браузере
- Откройте DevTools и проверьте ошибки в консоли
- Убедитесь, что скрипт поиска корректен
### 3. Ошибка компиляции TypeScript
**Проблема:** `TS2345: Argument of type...`
**Решение:**
```bash
# Очистить и переустановить зависимости
rm -rf node_modules
npm install
```
### 4. Vite не запускается
**Проблема:** `Port 3000 is already in use`
**Решение:**
- Остановите другие процессы на порту 3000
- Или измените порт в `vite.config.ts`:
```typescript
server: {
port: 3001,
},
```
## Полезные команды
```bash
# Установка зависимостей
npm install
# Разработка
npm run dev
# Сборка
npm run build
# Создание установщика (Windows)
npm run package:win
# Очистка
rm -rf dist/ release/ node_modules/
npm install
```
## Следующие шаги
1. Прочитайте [README.md](./README.md) для полной документации
2. Изучите [REQUIREMENTS.md](./REQUIREMENTS.md) для детальных требований
3. Прочитайте [SEARCH_SCRIPTS.md](./SEARCH_SCRIPTS.md) для создания своих скриптов
4. Проверьте примеры скриптов в `search-scripts/`
## Поддержка
Если возникли проблемы:
1. Проверьте логи в терминале и DevTools
2. Прочитайте документацию
3. Создайте Issue на GitHub с описанием проблемы
---
**Готово!** Теперь вы можете начать работу с Media Center 🎬

327
README.md Normal file
View File

@@ -0,0 +1,327 @@
# Media Center v2
Electron-приложение для просмотра фильмов и сериалов с глобальным поиском по множеству сайтов.
## Возможности
- **Глобальный поиск** - поиск фильмов на всех подключенных сайтах одновременно
- **Кастомные поисковые скрипты** - для каждого сайта свой скрипт поиска
- **Закладки** - сохранение избранных фильмов и сериалов
- **Управление вкладками** - просмотр и переключение между открытыми страницами
- **Прокси поддержка** - автоматическое использование прокси для заблокированных сайтов
- **Автообновления** - проверка и установка новых версий
- **Расширения браузера** - AdBlock и FoxyProxy
## Технологический стек
- **Electron 28** - кроссплатформенный фреймворк
- **React 18** - UI библиотека
- **TypeScript** - язык разработки
- **Vite** - сборщик для frontend
- **Axios** - HTTP клиент
- **Cheerio** - HTML парсинг
- **NeDB** - встроенная база данных
## Установка и запуск
### Требования
- Node.js 18+ (для разработки)
- npm или yarn
### Установка зависимостей
```bash
npm install
```
### Разработка
Запуск в режиме разработки:
```bash
npm run dev
```
Это запустит:
- Vite dev server для React (порт 3000)
- TypeScript компиляцию main process
- Electron приложение с hot reload
### Сборка
Сборка всего проекта:
```bash
npm run build
```
Создание установщика для Windows:
```bash
npm run package:win
```
Создание установщика для macOS:
```bash
npm run package:mac
```
Создание установщика для Linux:
```bash
npm run package:linux
```
Готовый установщик будет в папке `release/`.
## Структура проекта
```
media-center/
├── src/
│ ├── main/ # Главный процесс Electron
│ │ ├── index.ts # Точка входа
│ │ ├── proxy.ts # Управление прокси
│ │ ├── config.ts # Управление конфигурацией
│ │ ├── database.ts # База данных
│ │ ├── tabs.ts # Управление вкладками
│ │ ├── search.ts # Система поиска
│ │ ├── updater.ts # Обновления
│ │ └── preload.ts # Preload скрипт
│ │
│ ├── renderer/ # Renderer процесс (React)
│ │ ├── App.tsx # Главный компонент
│ │ ├── pages/ # Страницы
│ │ ├── components/ # Компоненты
│ │ └── styles/ # CSS стили
│ │
│ └── shared/ # Общий код
│ ├── types.ts # TypeScript типы
│ └── constants.ts # Константы
├── config/ # Конфигурации
│ └── sites.json # Настройки сайтов
├── search-scripts/ # Поисковые скрипты
│ ├── kinogo.js
│ ├── rutube.js
│ ├── hdrezka.js
│ ├── SCRIPT_TEMPLATE.js
│ └── README.md
├── REQUIREMENTS.md # Детальные требования
├── SEARCH_SCRIPTS.md # Документация по скриптам
└── README.md # Этот файл
```
## Использование
### Главная страница
Отображает карточки с доступными сайтами. Клик по карточке открывает сайт в новой вкладке.
### Глобальный поиск
1. Введите название фильма в поисковую строку
2. Нажмите "Найти"
3. Просмотрите результаты со всех сайтов
4. Кликните на результат для открытия страницы фильма
### Закладки
1. При просмотре фильма добавьте его в закладки
2. Все закладки доступны на странице "Закладки"
3. Фильтруйте закладки по сайтам
4. Удаляйте ненужные закладки
### Активные вкладки
- Просмотр всех открытых вкладок
- Переключение между вкладками
- Закрытие вкладок
### Настройки
**Конфигурация:**
- URL сервера для обновления конфигураций
- Обновление списка сайтов и скриптов
**Прокси:**
- Статус прокси (запущен/остановлен)
- Автозапуск при старте приложения
- Ручное управление прокси
**Интерфейс:**
- Выбор темы (светлая/темная)
- Выбор языка (русский/английский)
**Обновления:**
- Проверка доступных обновлений
- Автоматическая загрузка и установка
## Поисковые скрипты
Каждый сайт имеет свой JavaScript скрипт для поиска. Скрипты выполняются в контексте Electron без необходимости внешнего Node.js.
### Создание нового скрипта
1. Скопируйте `search-scripts/SCRIPT_TEMPLATE.js`
2. Назовите файл по имени сайта (например, `mysite.js`)
3. Реализуйте функцию `search()`:
```javascript
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
// Ваш код поиска
return [
{
name: "Название фильма",
url: "https://site.com/movie/123",
image: "https://site.com/poster.jpg", // опционально
year: "2023", // опционально
description: "Описание" // опционально
}
];
}
```
4. Добавьте сайт в `config/sites.json`:
```json
{
"id": "mysite",
"name": "My Site",
"url": "https://mysite.com",
"logo": "https://mysite.com/favicon.ico",
"enabled": true,
"useProxy": false,
"searchScript": "mysite.js"
}
```
Подробнее см. [SEARCH_SCRIPTS.md](./SEARCH_SCRIPTS.md)
## Прокси (InvisibleManXRay)
Для доступа к заблокированным сайтам используется InvisibleManXRay прокси.
### Установка
1. Скачайте [InvisibleManXRay CLI](https://github.com/...)
2. Установите в системный PATH или укажите путь в `src/shared/constants.ts`
3. Приложение запустит прокси автоматически (если включен автозапуск)
### Настройка
В `src/shared/constants.ts`:
```typescript
export const INVISIBLE_MAN_CLI_PATH = 'invisibleManXRay'; // или полный путь
export const DEFAULT_PROXY_PORT = 10808;
```
## Серверная часть (Backend API)
Для полной функциональности нужен сервер с API:
### GET /api/config/sites
Возвращает конфигурацию сайтов:
```json
{
"version": "1.0.0",
"lastUpdated": "2025-10-14T12:00:00Z",
"sites": [...]
}
```
### GET /api/version/check
Проверка обновлений:
```json
{
"latestVersion": "1.1.0",
"updateAvailable": true,
"downloadUrl": "https://server.com/downloads/media-center-1.1.0.exe",
"changelog": "- Новые функции...",
"releaseDate": "2025-10-14",
"mandatory": false
}
```
### GET /api/downloads/{version}/{platform}
Скачивание установщика.
Подробнее см. [REQUIREMENTS.md](./REQUIREMENTS.md)
## Разработка
### Добавление нового сайта
1. Создайте поисковый скрипт в `search-scripts/`
2. Добавьте сайт в `config/sites.json`
3. Протестируйте поиск
### Отладка
- Откройте DevTools: F12
- Логи main process: `console.log` видны в терминале
- Логи renderer process: видны в DevTools Console
- Ошибки скриптов: проверьте консоль при выполнении поиска
### TypeScript
Все типы находятся в `src/shared/types.ts`. Для добавления новых типов:
```typescript
export interface MyType {
field: string;
}
```
## Безопасность
- Скрипты выполняются в изолированном контексте
- Context Isolation включена
- Node Integration отключена в BrowserView
- Строгая Content Security Policy
- Таймауты для всех операций
## Производительность
- Ленивая загрузка вкладок
- Кэширование результатов поиска
- Параллельные запросы при поиске
- Оптимизация памяти
## Известные проблемы
1. **Прокси не запускается**: Убедитесь, что InvisibleManXRay установлен
2. **Поиск не работает**: Проверьте доступность сайтов
3. **Обновления не скачиваются**: Проверьте URL сервера в настройках
## Лицензия
MIT
## Автор
George
## Поддержка
Для вопросов и предложений создайте Issue в GitHub.
## Roadmap
- [ ] История просмотров
- [ ] Рекомендации на основе закладок
- [ ] Синхронизация между устройствами
- [ ] Встроенный видеоплеер
- [ ] Автоматическая загрузка субтитров
- [ ] Торрент интеграция
- [ ] Уведомления о новых сериях

351
REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,351 @@
# Media Center v2 - Детальное Описание и Требования
## Общее Описание
Electron-приложение представляет собой специализированный медиацентр для просмотра фильмов и сериалов. Это кастомный браузер, оптимизированный для работы с различными онлайн-кинотеатрами и видеохостингами.
## Основные Функции
### 1. Главная Страница (Home)
- **Назначение**: Отображение карточек с предзаготовленными сайтами для просмотра фильмов
- **Функционал**:
- Отображение карточек сайтов (название, логотип, краткое описание)
- Клик по карточке открывает сайт в новой вкладке
- Возможность добавления новых сайтов через конфигурацию
- Индикация доступности сайта (онлайн/офлайн)
### 2. Закладки (Bookmarks)
- **Назначение**: Сохранение избранных фильмов и сериалов
- **Функционал**:
- Карточки с сохраненными фильмами/сериалами
- Информация: название, постер, сайт-источник, прямая ссылка
- Возможность удаления закладок
- Клик открывает сохраненную страницу
- Группировка по сайтам (опционально)
### 3. Активные Вкладки (Active Tabs)
- **Назначение**: Управление открытыми вкладками
- **Функционал**:
- Список всех открытых вкладок
- Превью страницы (скриншот или favicon + title)
- Переключение между вкладками
- Закрытие вкладок
- Возможность добавить текущую страницу в закладки
### 4. Глобальный Поиск
- **Назначение**: Поиск фильмов на всех доступных сайтах одновременно
- **Расположение**: Верхняя часть интерфейса (всегда доступен)
- **Функционал**:
- Поисковая строка с автодополнением
- Параллельный поиск на всех сайтах из конфигурации
- Агрегация результатов с разных сайтов
- Отображение результатов с указанием источника
- Фильтрация результатов по сайтам
- Индикация прогресса поиска
### 5. Система Конфигураций Сайтов
- **Назначение**: Управление настройками парсинга для каждого сайта
- **Структура конфигурации**:
```json
{
"version": "1.0.0",
"sites": [
{
"id": "site_unique_id",
"name": "Название сайта",
"url": "https://example.com",
"logo": "url_или_base64",
"enabled": true,
"useProxy": true,
"search": {
"endpoint": "/search",
"method": "GET",
"params": {
"query": "{searchQuery}"
},
"headers": {},
"responseType": "json",
"parsing": {
"type": "json",
"resultsPath": "data.results",
"mapping": {
"title": "title",
"url": "link",
"image": "poster",
"year": "year"
}
}
}
}
]
}
```
- **Поддерживаемые типы парсинга**:
- JSON (стандартный API ответ)
- HTML (парсинг DOM через селекторы)
- XML (парсинг XML структуры)
### 6. Настройки (Settings)
- **Конфигурация сайтов**:
- Просмотр текущей конфигурации
- URL сервера конфигураций (редактируемый)
- Кнопка "Обновить конфигурацию" - загрузка с сервера
- Последняя дата обновления
- **Прокси настройки**:
- Статус прокси (запущен/остановлен)
- Включение/отключение автозапуска прокси
- Настройки InvisibleManXRay CLI
- **Общие настройки**:
- Тема оформления (светлая/темная)
- Язык интерфейса
- Папка для загрузок
- Очистка кэша
### 7. Система Версионирования и Обновлений
- **Проверка версий**:
- Автоматическая проверка при запуске
- Ручная проверка через меню
- Сравнение текущей версии с серверной
- **Процесс обновления**:
- Уведомление о доступности новой версии
- Отображение changelog (что нового)
- Кнопка "Обновить сейчас" / "Напомнить позже"
- Скачивание нового установщика
- Запуск установки с закрытием текущего приложения
### 8. Прокси Интеграция
- **InvisibleManXRay CLI**:
- Автозапуск при старте приложения
- Проверка статуса прокси
- Логирование работы прокси
- Возможность перезапуска
- **FoxyProxy Integration**:
- Автоматическое переключение прокси для сайтов
- Правила на основе конфигурации (useProxy: true/false)
- Прямое подключение для сайтов без прокси
### 9. Расширения Браузера
- **AdBlock**:
- Блокировка рекламы на всех сайтах
- Настройка белого списка (если нужно)
- **FoxyProxy**:
- Управление прокси правилами
- Автоматическое переключение на основе URL
## Технический Стек
### Frontend
- **Electron**: Основной фреймворк
- **React**: UI библиотека
- **TypeScript**: Язык разработки
- **Electron BrowserView/WebView**: Для встраивания веб-страниц
- **CSS/SCSS**: Стилизация
- **Axios**: HTTP запросы
- **Cheerio**: HTML парсинг (для поиска)
### Backend (Main Process)
- **Node.js**: Серверная логика
- **SQLite/NeDB**: Локальная БД для закладок и настроек
- **Child Process**: Управление InvisibleManXRay CLI
- **Electron Store**: Хранение настроек
### Расширения
- **uBlock Origin / AdBlock Plus**: Блокировка рекламы
- **FoxyProxy**: Управление прокси
## Архитектура Приложения
```
media-center/
├── src/
│ ├── main/ # Main process
│ │ ├── index.ts # Точка входа
│ │ ├── proxy.ts # Управление прокси
│ │ ├── config.ts # Управление конфигурацией
│ │ ├── updater.ts # Система обновлений
│ │ └── database.ts # Работа с БД
│ │
│ ├── renderer/ # Renderer process
│ │ ├── App.tsx # Главный компонент
│ │ ├── pages/
│ │ │ ├── Home.tsx
│ │ │ ├── Bookmarks.tsx
│ │ │ ├── ActiveTabs.tsx
│ │ │ └── Settings.tsx
│ │ ├── components/
│ │ │ ├── SearchBar.tsx
│ │ │ ├── SiteCard.tsx
│ │ │ ├── BookmarkCard.tsx
│ │ │ ├── TabCard.tsx
│ │ │ └── BrowserView.tsx
│ │ └── services/
│ │ ├── search.ts # Сервис поиска
│ │ ├── config.ts # Работа с конфигурацией
│ │ └── api.ts # API клиент
│ │
│ ├── shared/ # Общий код
│ │ ├── types.ts # TypeScript типы
│ │ └── constants.ts # Константы
│ │
│ └── extensions/ # Браузерные расширения
│ ├── adblock/
│ └── foxyproxy/
├── config/ # Конфигурационные файлы
│ └── sites.json # Дефолтная конфигурация сайтов
├── package.json
├── electron-builder.json # Настройки сборки
└── tsconfig.json
```
## Требования к Серверной Части (Backend API)
### 1. Конфигурация Сайтов
**Endpoint**: `GET /api/config/sites`
**Ответ**:
```json
{
"version": "1.2.0",
"lastUpdated": "2025-10-14T12:00:00Z",
"sites": [...]
}
```
### 2. Проверка Версии
**Endpoint**: `GET /api/version/check`
**Параметры**: `?currentVersion=1.0.0`
**Ответ**:
```json
{
"latestVersion": "1.1.0",
"updateAvailable": true,
"downloadUrl": "https://server.com/downloads/media-center-1.1.0.exe",
"changelog": "- Добавлен новый сайт\n- Исправлены ошибки поиска",
"releaseDate": "2025-10-14",
"mandatory": false
}
```
### 3. Скачивание Обновления
**Endpoint**: `GET /api/downloads/{version}/{platform}`
**Ответ**: Бинарный файл установщика
### 4. Статистика (опционально)
**Endpoint**: `POST /api/stats/usage`
**Тело**:
```json
{
"appVersion": "1.0.0",
"event": "search",
"site": "kinogo",
"timestamp": "2025-10-14T12:00:00Z"
}
```
## Основные Сценарии Использования
### Сценарий 1: Поиск фильма
1. Пользователь вводит название в глобальный поиск
2. Приложение отправляет запросы на все сайты параллельно
3. Результаты агрегируются и отображаются
4. Пользователь выбирает результат и открывается страница фильма
5. Возможность добавить в закладки
### Сценарий 2: Работа с закладками
1. Пользователь открывает страницу фильма
2. Нажимает "Добавить в закладки"
3. Закладка сохраняется с метаданными
4. Закладка отображается на странице "Закладки"
5. Быстрый доступ к сохраненному фильму
### Сценарий 3: Обновление конфигурации
1. Пользователь открывает "Настройки"
2. Нажимает "Обновить конфигурацию"
3. Приложение загружает новую конфигурацию с сервера
4. Применяет изменения (новые сайты, обновленные правила парсинга)
5. Уведомляет пользователя об успехе
### Сценарий 4: Обновление приложения
1. При запуске приложение проверяет версию
2. Если доступно обновление - показывает уведомление
3. Пользователь может обновить сейчас или позже
4. При согласии скачивается новый установщик
5. Приложение закрывается и запускается установка
## UI/UX Требования
### Дизайн
- **Стиль**: Современный, минималистичный
- **Цветовая схема**: Темная тема по умолчанию (с возможностью переключения)
- **Шрифты**: Читабельные, sans-serif (например, Inter, Roboto)
- **Иконки**: Использовать библиотеку (например, Lucide, Heroicons)
### Навигация
- **Главное меню**: Боковая панель или верхнее меню с вкладками
- **Поиск**: Всегда доступен в верхней части
- **Быстрые действия**: Контекстное меню на карточках
### Адаптивность
- Поддержка разных разрешений экрана
- Минимальное разрешение: 1280x720
- Оптимально: 1920x1080
## Безопасность
1. **Санитизация URL**: Проверка и очистка всех внешних URL
2. **Content Security Policy**: Строгая CSP для renderer process
3. **Node Integration**: Отключена в BrowserView
4. **Context Isolation**: Включена
5. **Безопасное хранение**: Конфиденциальные данные в зашифрованном виде
## Производительность
1. **Ленивая загрузка**: Вкладки загружаются только при активации
2. **Кэширование**: Результаты поиска и конфигурации кэшируются
3. **Оптимизация памяти**: Автоматическое закрытие неиспользуемых вкладок
4. **Параллельные запросы**: Поиск выполняется параллельно
## Этапы Разработки
### Фаза 1: Базовая структура
- [x] Настройка Electron проекта
- [ ] Основная архитектура (main + renderer)
- [ ] Базовый UI с навигацией
### Фаза 2: Основной функционал
- [ ] Система конфигураций
- [ ] Страница Home с карточками сайтов
- [ ] BrowserView интеграция
- [ ] Система закладок
### Фаза 3: Продвинутый функционал
- [ ] Глобальный поиск
- [ ] Парсинг для разных типов ответов
- [ ] Управление активными вкладками
### Фаза 4: Прокси и расширения
- [ ] Интеграция InvisibleManXRay
- [ ] Установка AdBlock
- [ ] Настройка FoxyProxy
### Фаза 5: Обновления и полировка
- [ ] Система версионирования
- [ ] Автообновление
- [ ] Финальная оптимизация
- [ ] Тестирование
## Дополнительные Возможности (Future Features)
1. **История просмотров**: Отслеживание просмотренного контента
2. **Рекомендации**: На основе истории и закладок
3. **Синхронизация**: Между устройствами (через облако)
4. **Плеер интеграция**: Встроенный видеоплеер
5. **Субтитры**: Автоматическая загрузка субтитров
6. **Торрент интеграция**: Загрузка через торренты
7. **Уведомления**: О новых сериях любимых сериалов

271
SEARCH_SCRIPTS.md Normal file
View File

@@ -0,0 +1,271 @@
# Система Поисковых Скриптов
## Обзор
Media Center использует систему кастомных JavaScript скриптов для поиска контента на различных сайтах. Каждый сайт имеет свой собственный скрипт, который знает, как выполнять поиск на этом конкретном сайте.
## Преимущества подхода
1. **Гибкость**: Каждый сайт может иметь уникальную логику поиска
2. **Расширяемость**: Легко добавлять новые сайты без изменения основного кода
3. **Обновляемость**: Скрипты можно обновлять с сервера
4. **Нет зависимости от Node.js**: Скрипты выполняются в контексте Electron
## Структура скрипта
Каждый скрипт - это JavaScript файл, который экспортирует функцию `search`:
```javascript
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
// Ваша логика поиска
return [
{
name: "Название фильма",
url: "https://site.com/movie/123",
image: "https://site.com/poster.jpg", // опционально
year: "2023", // опционально
description: "Описание фильма", // опционально
rating: "8.5" // опционально
}
];
}
```
### Параметры функции
- `query` (string) - поисковый запрос пользователя
- `siteUrl` (string) - базовый URL сайта
- `useProxy` (boolean) - флаг использования прокси
- `axios` - HTTP клиент для запросов
- `cheerio` - библиотека для парсинга HTML
- `proxyConfig` - объект с настройками прокси `{host, port}`
### Возвращаемое значение
Массив объектов с результатами. Обязательные поля:
- `name` - название фильма/сериала
- `url` - ссылка на страницу
Опциональные поля:
- `image` - URL постера
- `year` - год выпуска
- `description` - краткое описание
- `rating` - рейтинг
## Доступные инструменты
### axios
HTTP клиент для выполнения запросов:
```javascript
// GET запрос
const response = await axios.get('https://site.com/search', {
params: { q: query },
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0...'
}
});
// POST запрос
const response = await axios.post('https://site.com/search', {
query: query
}, {
timeout: 15000
});
// С прокси
if (useProxy && proxyConfig) {
const response = await axios.get(url, {
proxy: {
host: proxyConfig.host,
port: proxyConfig.port
}
});
}
```
### cheerio
jQuery-подобная библиотека для парсинга HTML:
```javascript
const $ = cheerio.load(html);
// Поиск элементов
const title = $('.movie-title').text();
const link = $('a.movie-link').attr('href');
// Итерация по элементам
$('.movie-card').each((index, element) => {
const $el = $(element);
const name = $el.find('.title').text().trim();
const url = $el.find('a').attr('href');
});
```
## Примеры реализаций
### Пример 1: HTML парсинг (Kinogo)
```javascript
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
const config = {
params: { do: 'search', subaction: 'search', story: query },
timeout: 15000,
headers: { 'User-Agent': 'Mozilla/5.0...' }
};
if (useProxy && proxyConfig) {
config.proxy = { host: proxyConfig.host, port: proxyConfig.port };
}
const response = await axios.get(`${siteUrl}/index.php`, config);
const $ = cheerio.load(response.data);
const results = [];
$('.shortstory').each((i, el) => {
const $item = $(el);
const name = $item.find('.title a').text().trim();
const url = $item.find('.title a').attr('href');
const image = $item.find('img').attr('src');
if (name && url) {
results.push({ name, url: siteUrl + url, image });
}
});
return results;
}
```
### Пример 2: JSON API (Rutube)
```javascript
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
const config = {
params: { query, limit: 20 },
timeout: 15000
};
const response = await axios.get(`${siteUrl}/api/search/video/`, config);
const data = response.data;
if (!data.results) return [];
return data.results.map(item => ({
name: item.title,
url: item.video_url || `${siteUrl}/video/${item.id}`,
image: item.thumbnail_url,
description: item.description
}));
}
```
## Хранение скриптов
Скрипты хранятся в двух местах:
1. **Встроенные скрипты**: `<app>/search-scripts/`
- Поставляются с приложением
- Только для чтения
- Используются по умолчанию
2. **Пользовательские скрипты**: `<userData>/search-scripts/`
- Загружаются с сервера
- Можно обновлять
- Имеют приоритет над встроенными
## Конфигурация сайта
В `config/sites.json`:
```json
{
"id": "kinogo",
"name": "Kinogo",
"url": "https://kinogo.biz",
"logo": "https://kinogo.biz/favicon.ico",
"enabled": true,
"useProxy": true,
"searchScript": "kinogo.js"
}
```
## Создание нового скрипта
1. Скопируйте `SCRIPT_TEMPLATE.js`
2. Назовите файл по имени сайта (например, `mysite.js`)
3. Реализуйте функцию `search()`
4. Протестируйте с разными запросами
5. Добавьте сайт в конфигурацию
## Обновление скриптов с сервера
Пользователи могут обновлять скрипты через настройки приложения:
1. Настройки → Конфигурация
2. Кнопка "Обновить конфигурацию"
3. Скачиваются новые скрипты и конфигурация
4. Применяются автоматически
## Безопасность
- Скрипты выполняются в изолированном контексте
- Таймаут выполнения: 30 секунд
- Нет доступа к файловой системе
- Нет доступа к другим модулям Node.js
- Только axios и cheerio
## Отладка
Для отладки скриптов:
1. Откройте DevTools (F12 в приложении)
2. Выполните поиск
3. Проверьте консоль на наличие ошибок
4. Логи включают:
- Найденное количество результатов
- Ошибки выполнения
- Невалидные результаты
## Требования к серверу
Сервер должен предоставлять:
### GET /api/config/sites
Возвращает конфигурацию со списком сайтов и ссылками на скрипты:
```json
{
"version": "1.0.0",
"lastUpdated": "2025-10-14T12:00:00Z",
"sites": [
{
"id": "kinogo",
"name": "Kinogo",
"url": "https://kinogo.biz",
"logo": "https://kinogo.biz/favicon.ico",
"enabled": true,
"useProxy": true,
"searchScript": "kinogo.js",
"scriptUrl": "https://server.com/scripts/kinogo.js"
}
]
}
```
### GET /scripts/{scriptName}
Возвращает содержимое скрипта (JavaScript файл).
## Best Practices
1. **Обработка ошибок**: Всегда оборачивайте код в try-catch
2. **Таймауты**: Устанавливайте разумные таймауты для запросов
3. **Валидация**: Проверяйте наличие обязательных полей
4. **User-Agent**: Используйте реалистичный User-Agent
5. **Относительные URL**: Преобразуйте в абсолютные
6. **Пустые результаты**: Возвращайте `[]` при ошибке, а не `null`

33
config/sites.json Normal file
View File

@@ -0,0 +1,33 @@
{
"version": "1.0.0",
"lastUpdated": "2025-10-14T12:00:00Z",
"sites": [
{
"id": "kinogo",
"name": "Kinogo",
"url": "https://kinogo.biz",
"logo": "https://kinogo.biz/favicon.ico",
"enabled": true,
"useProxy": true,
"searchScript": "kinogo.js"
},
{
"id": "rutube",
"name": "Rutube",
"url": "https://rutube.ru",
"logo": "https://rutube.ru/favicon.ico",
"enabled": true,
"useProxy": false,
"searchScript": "rutube.js"
},
{
"id": "hdrezka",
"name": "HDRezka",
"url": "https://hdrezka.ag",
"logo": "https://hdrezka.ag/favicon.ico",
"enabled": true,
"useProxy": true,
"searchScript": "hdrezka.js"
}
]
}

6748
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

80
package.json Normal file
View File

@@ -0,0 +1,80 @@
{
"name": "media-center",
"version": "1.0.1",
"description": "Media Center - Browser for watching movies with global search and proxy support",
"main": "dist/main/main/index.js",
"author": "George",
"license": "MIT",
"scripts": {
"start": "electron .",
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"",
"dev:main": "tsc -p tsconfig.main.json --watch",
"dev:renderer": "vite",
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
"build": "npm run build:main && npm run build:renderer",
"build:main": "tsc -p tsconfig.main.json",
"build:renderer": "vite build",
"package": "npm run build && electron-builder",
"package:win": "npm run build && electron-builder --win",
"package:mac": "npm run build && electron-builder --mac",
"package:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"axios": "^1.6.2",
"cheerio": "^1.0.0-rc.12",
"electron-store": "^8.1.0",
"nedb": "^1.8.0",
"uuid": "^9.0.1",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@types/nedb": "^1.8.16",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.7",
"@types/xml2js": "^0.4.14",
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"typescript": "^5.3.3",
"vite": "^5.0.8",
"vite-plugin-electron": "^0.28.0",
"wait-on": "^7.2.0"
},
"build": {
"appId": "com.media.center",
"productName": "Media Center",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"node_modules/**/*",
"package.json"
],
"win": {
"target": [
"nsis"
],
"icon": "assets/icon.ico"
},
"mac": {
"target": [
"dmg"
],
"icon": "assets/icon.icns"
},
"linux": {
"target": [
"AppImage"
],
"icon": "assets/icon.png"
}
}
}

135
search-scripts/README.md Normal file
View File

@@ -0,0 +1,135 @@
# Поисковые Скрипты для Media Center
Эта папка содержит кастомные JavaScript скрипты для поиска фильмов и сериалов на различных сайтах.
## Как работают скрипты
Каждый скрипт выполняется в защищенном контексте Electron и получает доступ к следующим инструментам:
- **axios** - для HTTP запросов
- **cheerio** - для парсинга HTML (jQuery-подобный синтаксис)
- **proxyConfig** - настройки прокси (если включен для сайта)
## Структура скрипта
Каждый скрипт должен экспортировать функцию `search` со следующей сигнатурой:
```javascript
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
// Ваш код поиска
return [{name: "Название фильма", url: "https://..."}];
}
```
### Параметры:
- `query` (string) - поисковый запрос пользователя
- `siteUrl` (string) - базовый URL сайта (например, "https://kinogo.biz")
- `useProxy` (boolean) - нужно ли использовать прокси
- `axios` (object) - экземпляр axios для HTTP запросов
- `cheerio` (object) - библиотека для парсинга HTML
- `proxyConfig` (object) - настройки прокси `{host: string, port: number}`
### Возвращаемое значение:
Массив объектов с результатами поиска. Каждый объект должен содержать:
**Обязательные поля:**
- `name` (string) - название фильма/сериала
- `url` (string) - ссылка на страницу фильма
**Опциональные поля:**
- `image` (string) - URL постера
- `year` (string) - год выпуска
- `description` (string) - описание
- `rating` (string) - рейтинг
## Примеры
### Пример 1: JSON API
```javascript
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
const config = {
params: { q: query },
timeout: 15000
};
if (useProxy && proxyConfig) {
config.proxy = { host: proxyConfig.host, port: proxyConfig.port };
}
const response = await axios.get(`${siteUrl}/api/search`, config);
return response.data.results.map(item => ({
name: item.title,
url: item.link,
image: item.poster
}));
}
```
### Пример 2: HTML парсинг
```javascript
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
const config = {
params: { q: query },
timeout: 15000
};
if (useProxy && proxyConfig) {
config.proxy = { host: proxyConfig.host, port: proxyConfig.port };
}
const response = await axios.get(`${siteUrl}/search`, config);
const $ = cheerio.load(response.data);
const results = [];
$('.movie-card').each((i, el) => {
const $el = $(el);
results.push({
name: $el.find('.title').text().trim(),
url: siteUrl + $el.find('a').attr('href'),
image: $el.find('img').attr('src')
});
});
return results;
}
```
## Добавление нового скрипта
1. Создайте файл `sitename.js` в этой папке
2. Используйте `SCRIPT_TEMPLATE.js` как основу
3. Реализуйте функцию `search()`
4. Обновите конфигурацию сайта в настройках приложения
## Обновление скриптов
Скрипты можно обновлять с сервера через настройки приложения. При обновлении конфигурации новые скрипты автоматически загружаются.
## Расположение скриптов
Скрипты хранятся в двух местах:
1. **Встроенные скрипты**: `<app>/search-scripts/` (только для чтения)
2. **Пользовательские скрипты**: `<userData>/search-scripts/` (можно обновлять)
Пользовательские скрипты имеют приоритет над встроенными.
## Безопасность
- Скрипты выполняются в изолированном контексте
- Таймаут выполнения: 30 секунд
- Доступ только к axios и cheerio
- Нет доступа к файловой системе или другим модулям Node.js
## Отладка
Ошибки скриптов логируются в консоль Electron DevTools. Для отладки:
1. Откройте DevTools (F12)
2. Выполните поиск
3. Проверьте консоль на наличие ошибок

View File

@@ -0,0 +1,96 @@
/**
* Search script template for creating custom site search scripts
*
* Copy this file and modify the search() function to work with your target site.
*
* Available tools:
* - axios: For making HTTP requests
* - cheerio: For parsing HTML (jQuery-like syntax)
* - proxyConfig: Proxy settings if useProxy is true
*
* @param {string} query - Search query entered by user
* @param {string} siteUrl - Base URL of the website (e.g., "https://example.com")
* @param {boolean} useProxy - Whether to use proxy for this request
* @param {object} axios - Axios HTTP client
* @param {object} cheerio - Cheerio HTML parser
* @param {object} proxyConfig - Proxy configuration {host: string, port: number}
* @returns {Promise<Array>} Array of results in format: [{name: string, url: string, image?: string, year?: string, description?: string, rating?: string}]
*/
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
try {
// 1. Build the search URL
const searchUrl = `${siteUrl}/search?q=${encodeURIComponent(query)}`;
// 2. Configure the request
const config = {
timeout: 15000, // 15 seconds timeout
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
};
// 3. Add proxy if needed
if (useProxy && proxyConfig) {
config.proxy = {
host: proxyConfig.host,
port: proxyConfig.port
};
}
// 4. Make the HTTP request
const response = await axios.get(searchUrl, config);
// 5a. If the response is JSON:
// const data = response.data;
// const results = data.items.map(item => ({
// name: item.title,
// url: item.link,
// image: item.thumbnail,
// year: item.year,
// description: item.synopsis
// }));
// 5b. If the response is HTML:
const html = response.data;
const $ = cheerio.load(html);
const results = [];
// Parse HTML and extract movie data
$('.movie-card').each((index, element) => {
const $item = $(element);
const name = $item.find('.movie-title').text().trim();
const url = $item.find('a').attr('href');
const image = $item.find('img').attr('src');
const year = $item.find('.year').text().trim();
const description = $item.find('.description').text().trim();
// Only add if name and url exist
if (name && url) {
results.push({
name,
url: url.startsWith('http') ? url : siteUrl + url,
image: image ? (image.startsWith('http') ? image : siteUrl + image) : undefined,
year: year || undefined,
description: description || undefined
});
}
});
// 6. Return the results
return results;
} catch (error) {
console.error('Search error:', error.message);
return []; // Return empty array on error
}
}
// Important notes:
// 1. The function MUST be named 'search'
// 2. It MUST return a Promise that resolves to an array
// 3. Each result MUST have 'name' and 'url' fields
// 4. Other fields (image, year, description, rating) are optional
// 5. Handle errors gracefully and return [] on failure
// 6. Test with different queries to ensure reliability

87
search-scripts/hdrezka.js Normal file
View File

@@ -0,0 +1,87 @@
/**
* HDRezka.ag search script
*
* This script searches for movies/series on hdrezka.ag
*
* @param {string} query - Search query from user
* @param {string} siteUrl - Base URL of the site
* @param {boolean} useProxy - Whether to use proxy for requests
* @param {object} axios - Axios instance for HTTP requests
* @param {object} cheerio - Cheerio instance for HTML parsing
* @param {object} proxyConfig - Proxy configuration {host, port}
* @returns {Promise<Array>} Array of search results [{name, url, image?, year?, description?}]
*/
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
try {
const searchUrl = `${siteUrl}/search/`;
// Prepare request config
const config = {
params: {
do: 'search',
subaction: 'search',
q: query
},
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8'
}
};
// Add proxy if needed
if (useProxy && proxyConfig) {
config.proxy = {
host: proxyConfig.host,
port: proxyConfig.port
};
}
// Make request
const response = await axios.get(searchUrl, config);
const html = response.data;
// Parse HTML
const $ = cheerio.load(html);
const results = [];
// Find all movie/series cards
$('.b-content__inline_item').each((index, element) => {
const $item = $(element);
// Extract title and URL
const linkEl = $item.find('.b-content__inline_item-link a').first();
const name = linkEl.text().trim();
const url = linkEl.attr('href');
if (!name || !url) return;
// Extract image
const imageEl = $item.find('.b-content__inline_item-cover img').first();
const image = imageEl.attr('src') || imageEl.attr('data-src');
// Extract year
const infoText = $item.find('.info').text();
const yearMatch = infoText.match(/(\d{4})/);
const year = yearMatch ? yearMatch[1] : '';
// Extract rating if available
const ratingText = $item.find('.imdb').text().trim();
results.push({
name,
url: url.startsWith('http') ? url : siteUrl + url,
image: image ? (image.startsWith('http') ? image : siteUrl + image) : undefined,
year,
rating: ratingText || undefined
});
});
return results;
} catch (error) {
console.error('HDRezka search error:', error.message);
return [];
}
}

85
search-scripts/kinogo.js Normal file
View File

@@ -0,0 +1,85 @@
/**
* Kinogo.biz search script
*
* This script searches for movies/series on kinogo.biz
*
* @param {string} query - Search query from user
* @param {string} siteUrl - Base URL of the site
* @param {boolean} useProxy - Whether to use proxy for requests
* @param {object} axios - Axios instance for HTTP requests
* @param {object} cheerio - Cheerio instance for HTML parsing
* @param {object} proxyConfig - Proxy configuration {host, port}
* @returns {Promise<Array>} Array of search results [{name, url, image?, year?, description?}]
*/
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
try {
const searchUrl = `${siteUrl}/index.php`;
// Prepare request config
const config = {
params: {
do: 'search',
subaction: 'search',
story: query
},
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
};
// Add proxy if needed
if (useProxy && proxyConfig) {
config.proxy = {
host: proxyConfig.host,
port: proxyConfig.port
};
}
// Make request
const response = await axios.get(searchUrl, config);
const html = response.data;
// Parse HTML
const $ = cheerio.load(html);
const results = [];
// Find all movie cards
$('.shortstory').each((index, element) => {
const $item = $(element);
// Extract title
const titleEl = $item.find('.shortstory-title a, .title a, h2 a').first();
const name = titleEl.text().trim();
const url = titleEl.attr('href');
if (!name || !url) return;
// Extract image
const imageEl = $item.find('.shortstory-img img, .poster img, img').first();
const image = imageEl.attr('src') || imageEl.attr('data-src');
// Extract year
const yearText = $item.find('.year, .shortstory-year').text().trim();
const yearMatch = yearText.match(/(\d{4})/);
const year = yearMatch ? yearMatch[1] : '';
// Extract description
const description = $item.find('.shortstory-desc, .description').text().trim();
results.push({
name,
url: url.startsWith('http') ? url : siteUrl + url,
image: image ? (image.startsWith('http') ? image : siteUrl + image) : undefined,
year,
description: description || undefined
});
});
return results;
} catch (error) {
console.error('Kinogo search error:', error.message);
return [];
}
}

63
search-scripts/rutube.js Normal file
View File

@@ -0,0 +1,63 @@
/**
* Rutube.ru search script
*
* This script searches for videos on rutube.ru using their API
*
* @param {string} query - Search query from user
* @param {string} siteUrl - Base URL of the site
* @param {boolean} useProxy - Whether to use proxy for requests
* @param {object} axios - Axios instance for HTTP requests
* @param {object} cheerio - Cheerio instance for HTML parsing (not used)
* @param {object} proxyConfig - Proxy configuration {host, port}
* @returns {Promise<Array>} Array of search results [{name, url, image?, description?}]
*/
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
try {
const searchUrl = `${siteUrl}/api/search/video/`;
// Prepare request config
const config = {
params: {
query: query,
limit: 20
},
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
};
// Add proxy if needed (Rutube usually doesn't require proxy in Russia)
if (useProxy && proxyConfig) {
config.proxy = {
host: proxyConfig.host,
port: proxyConfig.port
};
}
// Make request
const response = await axios.get(searchUrl, config);
const data = response.data;
// Check if results exist
if (!data.results || !Array.isArray(data.results)) {
return [];
}
// Map results to our format
const results = data.results.map(item => {
return {
name: item.title || '',
url: item.video_url || (siteUrl + '/video/' + item.id),
image: item.thumbnail_url || undefined,
description: item.description || undefined
};
});
return results.filter(r => r.name && r.url);
} catch (error) {
console.error('Rutube search error:', error.message);
return [];
}
}

103
src/main/config.ts Normal file
View File

@@ -0,0 +1,103 @@
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { ConfigData } from '../shared/types';
import { DEFAULT_CONFIG_SERVER_URL } from '../shared/constants';
export class ConfigManager {
private config: ConfigData | null = null;
private configPath: string;
private defaultConfigPath: string;
constructor(userDataPath: string) {
this.configPath = path.join(userDataPath, 'config.json');
// In dev: dist/main/main -> ../../../config/sites.json
// In production: resources/app.asar/dist/main/main -> ../../../config/sites.json
this.defaultConfigPath = path.join(__dirname, '../../../config/sites.json');
}
async loadConfig(): Promise<ConfigData> {
try {
// Try to load from user data
if (fs.existsSync(this.configPath)) {
const data = fs.readFileSync(this.configPath, 'utf-8');
this.config = JSON.parse(data);
console.log('Config loaded from user data');
} else {
// Load default config
const data = fs.readFileSync(this.defaultConfigPath, 'utf-8');
const defaultConfig = JSON.parse(data) as ConfigData;
this.config = defaultConfig;
// Save to user data
await this.saveConfig(defaultConfig);
console.log('Default config loaded and saved');
}
return this.config as ConfigData;
} catch (error) {
console.error('Error loading config:', error);
throw error;
}
}
async saveConfig(config: ConfigData): Promise<void> {
try {
const dir = path.dirname(this.configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
this.config = config;
console.log('Config saved');
} catch (error) {
console.error('Error saving config:', error);
throw error;
}
}
async getConfig(): Promise<ConfigData> {
if (!this.config) {
await this.loadConfig();
}
return this.config!;
}
async updateFromServer(serverUrl?: string): Promise<ConfigData> {
try {
const url = serverUrl || DEFAULT_CONFIG_SERVER_URL;
const endpoint = `${url}/config/sites`;
console.log(`Fetching config from: ${endpoint}`);
const response = await axios.get<ConfigData>(endpoint, {
timeout: 10000,
});
const newConfig = response.data;
// Validate config structure
if (!newConfig.version || !newConfig.sites || !Array.isArray(newConfig.sites)) {
throw new Error('Invalid config structure received from server');
}
// Save new config
await this.saveConfig(newConfig);
console.log(`Config updated to version ${newConfig.version}`);
return newConfig;
} catch (error: any) {
console.error('Error updating config from server:', error.message);
throw new Error(`Failed to update config: ${error.message}`);
}
}
getSiteById(siteId: string) {
return this.config?.sites.find((site) => site.id === siteId);
}
getEnabledSites() {
return this.config?.sites.filter((site) => site.enabled) || [];
}
}

147
src/main/database.ts Normal file
View File

@@ -0,0 +1,147 @@
import Datastore from 'nedb';
import * as path from 'path';
import { Bookmark, AppSettings } from '../shared/types';
import { DEFAULT_SETTINGS } from '../shared/constants';
import { v4 as uuidv4 } from 'uuid';
export class DatabaseManager {
private bookmarksDb: Datastore<any>;
private settingsDb: Datastore<any>;
constructor(userDataPath: string) {
this.bookmarksDb = new Datastore({
filename: path.join(userDataPath, 'bookmarks.db'),
autoload: false,
});
this.settingsDb = new Datastore({
filename: path.join(userDataPath, 'settings.db'),
autoload: false,
});
}
async init(): Promise<void> {
return new Promise((resolve, reject) => {
this.bookmarksDb.loadDatabase((err) => {
if (err) {
reject(err);
return;
}
this.settingsDb.loadDatabase((err) => {
if (err) {
reject(err);
return;
}
// Create indexes
this.bookmarksDb.ensureIndex({ fieldName: 'id', unique: true });
this.bookmarksDb.ensureIndex({ fieldName: 'siteId' });
console.log('Database initialized');
resolve();
});
});
});
}
// Bookmarks methods
async getBookmarks(): Promise<Bookmark[]> {
return new Promise((resolve, reject) => {
this.bookmarksDb
.find({})
.sort({ createdAt: -1 })
.exec((err, docs) => {
if (err) {
reject(err);
} else {
resolve(docs as Bookmark[]);
}
});
});
}
async addBookmark(bookmark: Omit<Bookmark, 'id' | 'createdAt'>): Promise<Bookmark> {
const newBookmark: Bookmark = {
...bookmark,
id: uuidv4(),
createdAt: new Date().toISOString(),
};
return new Promise((resolve, reject) => {
this.bookmarksDb.insert(newBookmark, (err, doc) => {
if (err) {
reject(err);
} else {
resolve(doc as Bookmark);
}
});
});
}
async removeBookmark(id: string): Promise<boolean> {
return new Promise((resolve, reject) => {
this.bookmarksDb.remove({ id }, {}, (err, numRemoved) => {
if (err) {
reject(err);
} else {
resolve(numRemoved > 0);
}
});
});
}
async getBookmarksBySite(siteId: string): Promise<Bookmark[]> {
return new Promise((resolve, reject) => {
this.bookmarksDb
.find({ siteId })
.sort({ createdAt: -1 })
.exec((err, docs) => {
if (err) {
reject(err);
} else {
resolve(docs as Bookmark[]);
}
});
});
}
// Settings methods
async getSettings(): Promise<AppSettings> {
return new Promise((resolve, reject) => {
this.settingsDb.findOne({ type: 'app-settings' }, (err, doc) => {
if (err) {
reject(err);
} else if (doc) {
resolve(doc as AppSettings);
} else {
// Return default settings if not found
resolve(DEFAULT_SETTINGS);
}
});
});
}
async saveSettings(settings: AppSettings): Promise<void> {
return new Promise((resolve, reject) => {
this.settingsDb.update(
{ type: 'app-settings' },
{ ...settings, type: 'app-settings' },
{ upsert: true },
(err) => {
if (err) {
reject(err);
} else {
resolve();
}
}
);
});
}
close(): void {
// NeDB doesn't require explicit closing, but we can compact databases
this.bookmarksDb.persistence.compactDatafile();
this.settingsDb.persistence.compactDatafile();
}
}

222
src/main/index.ts Normal file
View File

@@ -0,0 +1,222 @@
import { app, BrowserWindow, ipcMain, session } from 'electron';
import * as path from 'path';
import { ProxyManager } from './proxy';
import { ConfigManager } from './config';
import { DatabaseManager } from './database';
import { UpdaterManager } from './updater';
import { TabManager } from './tabs';
import { IPC_CHANNELS } from '../shared/types';
let mainWindow: BrowserWindow | null = null;
let proxyManager: ProxyManager;
let configManager: ConfigManager;
let databaseManager: DatabaseManager;
let updaterManager: UpdaterManager;
let tabManager: TabManager;
const isDevelopment = process.env.NODE_ENV !== 'production';
async function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 720,
minWidth: 1024,
minHeight: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
webSecurity: true,
},
autoHideMenuBar: true,
backgroundColor: '#1a1a1a',
});
if (isDevelopment) {
mainWindow.loadURL('http://localhost:3000');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../../dist/renderer/index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
async function initializeApp() {
// Initialize managers
const userDataPath = app.getPath('userData');
proxyManager = new ProxyManager();
configManager = new ConfigManager(userDataPath);
databaseManager = new DatabaseManager(userDataPath);
updaterManager = new UpdaterManager(configManager);
tabManager = new TabManager();
// Load initial configuration
await configManager.loadConfig();
await databaseManager.init();
// Setup browser extensions
await setupExtensions();
// Auto-start proxy if enabled
const settings = await databaseManager.getSettings();
if (settings.proxyAutoStart) {
await proxyManager.start();
}
// Check for updates
setTimeout(() => {
updaterManager.checkForUpdates().then((versionInfo) => {
if (versionInfo.updateAvailable && mainWindow) {
mainWindow.webContents.send('update-available', versionInfo);
}
});
}, 5000);
}
async function setupExtensions() {
try {
// Load extensions from extensions folder
const extensionsPath = path.join(__dirname, '../../extensions');
// Load uBlock Origin
const uBlockPath = path.join(extensionsPath, 'ublock');
if (require('fs').existsSync(uBlockPath)) {
await session.defaultSession.loadExtension(uBlockPath);
console.log('uBlock Origin loaded');
}
// Load FoxyProxy
const foxyProxyPath = path.join(extensionsPath, 'foxyproxy');
if (require('fs').existsSync(foxyProxyPath)) {
await session.defaultSession.loadExtension(foxyProxyPath);
console.log('FoxyProxy loaded');
}
} catch (error) {
console.error('Error loading extensions:', error);
}
}
function setupIpcHandlers() {
// Config handlers
ipcMain.handle(IPC_CHANNELS.GET_CONFIG, async () => {
return await configManager.getConfig();
});
ipcMain.handle(IPC_CHANNELS.UPDATE_CONFIG, async () => {
return await configManager.updateFromServer();
});
// Search handlers
ipcMain.handle(IPC_CHANNELS.SEARCH_ALL_SITES, async (_, query: string) => {
const config = await configManager.getConfig();
const { searchAllSites } = await import('./search');
return await searchAllSites(query, config.sites, proxyManager);
});
// Bookmark handlers
ipcMain.handle(IPC_CHANNELS.GET_BOOKMARKS, async () => {
return await databaseManager.getBookmarks();
});
ipcMain.handle(IPC_CHANNELS.ADD_BOOKMARK, async (_, bookmark) => {
return await databaseManager.addBookmark(bookmark);
});
ipcMain.handle(IPC_CHANNELS.REMOVE_BOOKMARK, async (_, id: string) => {
return await databaseManager.removeBookmark(id);
});
// Tab handlers
ipcMain.handle(IPC_CHANNELS.GET_TABS, async () => {
return tabManager.getTabs();
});
ipcMain.handle(IPC_CHANNELS.CREATE_TAB, async (_, url: string) => {
return tabManager.createTab(url, mainWindow!);
});
ipcMain.handle(IPC_CHANNELS.CLOSE_TAB, async (_, tabId: string) => {
return tabManager.closeTab(tabId);
});
ipcMain.handle(IPC_CHANNELS.ACTIVATE_TAB, async (_, tabId: string) => {
return tabManager.activateTab(tabId, mainWindow!);
});
ipcMain.handle(IPC_CHANNELS.HIDE_ACTIVE_TAB, async () => {
return tabManager.hideActiveTab(mainWindow!);
});
// Proxy handlers
ipcMain.handle(IPC_CHANNELS.GET_PROXY_STATUS, async () => {
return proxyManager.getStatus();
});
ipcMain.handle(IPC_CHANNELS.START_PROXY, async () => {
return await proxyManager.start();
});
ipcMain.handle(IPC_CHANNELS.STOP_PROXY, async () => {
return await proxyManager.stop();
});
// Version handlers
ipcMain.handle(IPC_CHANNELS.CHECK_VERSION, async () => {
return await updaterManager.checkForUpdates();
});
ipcMain.handle(IPC_CHANNELS.DOWNLOAD_UPDATE, async (_, downloadUrl: string) => {
return await updaterManager.downloadUpdate(downloadUrl);
});
// Settings handlers
ipcMain.handle(IPC_CHANNELS.GET_SETTINGS, async () => {
return await databaseManager.getSettings();
});
ipcMain.handle(IPC_CHANNELS.SAVE_SETTINGS, async (_, settings) => {
return await databaseManager.saveSettings(settings);
});
}
app.userAgentFallback = app.userAgentFallback
.replace(/\sElectron\/\S+/, '')
.replace(/\smedia[-_]center[^ ]*/gi, '');
app.whenReady().then(async () => {
await createWindow();
try {
await initializeApp();
} catch (error) {
console.error('Error initializing app:', error);
}
setupIpcHandlers();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', async () => {
// Cleanup
if (proxyManager) {
await proxyManager.stop();
}
if (databaseManager) {
databaseManager.close();
}
});

97
src/main/preload.ts Normal file
View File

@@ -0,0 +1,97 @@
import { contextBridge, ipcRenderer } from 'electron';
// IPC Channels (copied to avoid import issues in preload)
const IPC_CHANNELS = {
GET_CONFIG: 'config:get',
UPDATE_CONFIG: 'config:update',
SEARCH_ALL_SITES: 'search:all',
GET_BOOKMARKS: 'bookmarks:get',
ADD_BOOKMARK: 'bookmarks:add',
REMOVE_BOOKMARK: 'bookmarks:remove',
GET_TABS: 'tabs:get',
CREATE_TAB: 'tabs:create',
CLOSE_TAB: 'tabs:close',
ACTIVATE_TAB: 'tabs:activate',
HIDE_ACTIVE_TAB: 'tabs:hide',
GET_PROXY_STATUS: 'proxy:status',
START_PROXY: 'proxy:start',
STOP_PROXY: 'proxy:stop',
CHECK_VERSION: 'version:check',
DOWNLOAD_UPDATE: 'version:download',
GET_SETTINGS: 'settings:get',
SAVE_SETTINGS: 'settings:save',
};
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electronAPI', {
// Config
getConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_CONFIG),
updateConfig: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CONFIG),
// Search
searchAllSites: (query: string) =>
ipcRenderer.invoke(IPC_CHANNELS.SEARCH_ALL_SITES, query),
// Bookmarks
getBookmarks: () => ipcRenderer.invoke(IPC_CHANNELS.GET_BOOKMARKS),
addBookmark: (bookmark: any) =>
ipcRenderer.invoke(IPC_CHANNELS.ADD_BOOKMARK, bookmark),
removeBookmark: (id: string) =>
ipcRenderer.invoke(IPC_CHANNELS.REMOVE_BOOKMARK, id),
// Tabs
getTabs: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TABS),
createTab: (url: string) => ipcRenderer.invoke(IPC_CHANNELS.CREATE_TAB, url),
closeTab: (tabId: string) => ipcRenderer.invoke(IPC_CHANNELS.CLOSE_TAB, tabId),
activateTab: (tabId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.ACTIVATE_TAB, tabId),
hideActiveTab: () => ipcRenderer.invoke(IPC_CHANNELS.HIDE_ACTIVE_TAB),
// Proxy
getProxyStatus: () => ipcRenderer.invoke(IPC_CHANNELS.GET_PROXY_STATUS),
startProxy: () => ipcRenderer.invoke(IPC_CHANNELS.START_PROXY),
stopProxy: () => ipcRenderer.invoke(IPC_CHANNELS.STOP_PROXY),
// Version
checkVersion: () => ipcRenderer.invoke(IPC_CHANNELS.CHECK_VERSION),
downloadUpdate: (downloadUrl: string) =>
ipcRenderer.invoke(IPC_CHANNELS.DOWNLOAD_UPDATE, downloadUrl),
// Settings
getSettings: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SETTINGS),
saveSettings: (settings: any) =>
ipcRenderer.invoke(IPC_CHANNELS.SAVE_SETTINGS, settings),
// Event listeners
onUpdateAvailable: (callback: (versionInfo: any) => void) => {
ipcRenderer.on('update-available', (_, versionInfo) => callback(versionInfo));
},
});
// Type declaration for TypeScript
declare global {
interface Window {
electronAPI: {
getConfig: () => Promise<any>;
updateConfig: () => Promise<any>;
searchAllSites: (query: string) => Promise<any[]>;
getBookmarks: () => Promise<any[]>;
addBookmark: (bookmark: any) => Promise<any>;
removeBookmark: (id: string) => Promise<boolean>;
getTabs: () => Promise<any[]>;
createTab: (url: string) => Promise<any>;
closeTab: (tabId: string) => Promise<boolean>;
activateTab: (tabId: string) => Promise<boolean>;
hideActiveTab: () => Promise<boolean>;
getProxyStatus: () => Promise<any>;
startProxy: () => Promise<any>;
stopProxy: () => Promise<any>;
checkVersion: () => Promise<any>;
downloadUpdate: (downloadUrl: string) => Promise<string>;
getSettings: () => Promise<any>;
saveSettings: (settings: any) => Promise<void>;
onUpdateAvailable: (callback: (versionInfo: any) => void) => void;
};
}
}

145
src/main/proxy.ts Normal file
View File

@@ -0,0 +1,145 @@
import { spawn, ChildProcess } from 'child_process';
import { ProxyStatus } from '../shared/types';
import { DEFAULT_PROXY_PORT, INVISIBLE_MAN_CLI_PATH } from '../shared/constants';
export class ProxyManager {
private proxyProcess: ChildProcess | null = null;
private isRunning: boolean = false;
private port: number = DEFAULT_PROXY_PORT;
async start(): Promise<ProxyStatus> {
if (this.isRunning) {
return {
isRunning: true,
port: this.port,
};
}
try {
// Start InvisibleManXRay CLI
this.proxyProcess = spawn(INVISIBLE_MAN_CLI_PATH, ['run'], {
detached: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
this.proxyProcess.stdout?.on('data', (data) => {
console.log(`[Proxy STDOUT]: ${data}`);
// Check if proxy is ready
if (data.toString().includes('started') || data.toString().includes('running')) {
this.isRunning = true;
}
});
this.proxyProcess.stderr?.on('data', (data) => {
console.error(`[Proxy STDERR]: ${data}`);
});
this.proxyProcess.on('error', (error) => {
console.error('[Proxy Error]:', error);
this.isRunning = false;
});
this.proxyProcess.on('exit', (code) => {
console.log(`[Proxy] Process exited with code ${code}`);
this.isRunning = false;
this.proxyProcess = null;
});
// Wait for proxy to start
await new Promise((resolve) => setTimeout(resolve, 2000));
// Verify proxy is running
const status = await this.checkProxyHealth();
if (status) {
this.isRunning = true;
return {
isRunning: true,
port: this.port,
};
} else {
throw new Error('Proxy failed to start');
}
} catch (error: any) {
console.error('Failed to start proxy:', error);
return {
isRunning: false,
error: error.message,
};
}
}
async stop(): Promise<ProxyStatus> {
if (!this.isRunning || !this.proxyProcess) {
return {
isRunning: false,
};
}
try {
this.proxyProcess.kill('SIGTERM');
// Wait for process to exit
await new Promise((resolve) => {
if (this.proxyProcess) {
this.proxyProcess.on('exit', resolve);
setTimeout(resolve, 5000); // Timeout after 5 seconds
} else {
resolve(null);
}
});
this.isRunning = false;
this.proxyProcess = null;
return {
isRunning: false,
};
} catch (error: any) {
console.error('Failed to stop proxy:', error);
return {
isRunning: this.isRunning,
error: error.message,
};
}
}
getStatus(): ProxyStatus {
return {
isRunning: this.isRunning,
port: this.isRunning ? this.port : undefined,
};
}
private async checkProxyHealth(): Promise<boolean> {
try {
// Try to connect to proxy port to verify it's running
const axios = require('axios');
const response = await axios.get('http://www.google.com', {
proxy: {
host: '127.0.0.1',
port: this.port,
},
timeout: 5000,
}).catch(() => false);
return !!response;
} catch (error) {
// If connection test fails, just return true if process is alive
return this.proxyProcess !== null && !this.proxyProcess.killed;
}
}
getProxyConfig() {
if (!this.isRunning) {
return null;
}
return {
host: '127.0.0.1',
port: this.port,
protocol: 'http',
};
}
}

214
src/main/search.ts Normal file
View File

@@ -0,0 +1,214 @@
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import * as cheerio from 'cheerio';
import { SiteConfig, SearchResult, SearchResultWithSource } from '../shared/types';
import { ProxyManager } from './proxy';
import { app } from 'electron';
/**
* Search context object that will be passed to custom search scripts
*/
interface SearchContext {
query: string;
siteUrl: string;
useProxy: boolean;
axios: typeof axios;
cheerio: typeof cheerio;
proxyConfig?: {
host: string;
port: number;
};
}
/**
* Search all enabled sites using their custom scripts
*/
export async function searchAllSites(
query: string,
sites: SiteConfig[],
proxyManager: ProxyManager
): Promise<SearchResultWithSource[]> {
const enabledSites = sites.filter((site) => site.enabled);
const searchPromises = enabledSites.map((site) =>
searchSite(query, site, proxyManager)
.then((results) => {
// Add source information to each result
return results.map((result) => ({
...result,
source: site.id,
sourceName: site.name,
}));
})
.catch((error) => {
console.error(`Error searching ${site.name}:`, error.message);
return [];
})
);
const results = await Promise.all(searchPromises);
// Flatten and return all results
return results.flat();
}
/**
* Search a single site using its custom script
*/
export async function searchSite(
query: string,
site: SiteConfig,
proxyManager: ProxyManager
): Promise<SearchResult[]> {
try {
// Load the custom search script
const scriptPath = getScriptPath(site.searchScript);
if (!fs.existsSync(scriptPath)) {
console.error(`Search script not found: ${scriptPath}`);
return [];
}
const scriptContent = fs.readFileSync(scriptPath, 'utf-8');
// Prepare search context
const context: SearchContext = {
query,
siteUrl: site.url,
useProxy: site.useProxy,
axios,
cheerio,
};
// Add proxy config if needed
if (site.useProxy) {
const proxyConfig = proxyManager.getProxyConfig();
if (proxyConfig) {
context.proxyConfig = {
host: proxyConfig.host,
port: proxyConfig.port,
};
}
}
// Execute the custom script in a safe context
const results = await executeSearchScript(scriptContent, context);
// Validate results
if (!Array.isArray(results)) {
console.error(`Invalid results from ${site.name}: expected array`);
return [];
}
// Validate each result has required fields
const validResults = results.filter((result) => {
if (!result.name || !result.url) {
console.warn(`Invalid result from ${site.name}:`, result);
return false;
}
return true;
});
console.log(`Found ${validResults.length} results from ${site.name}`);
return validResults;
} catch (error: any) {
console.error(`Error executing search script for ${site.name}:`, error.message);
return [];
}
}
/**
* Execute custom search script in a controlled environment
*/
async function executeSearchScript(
scriptContent: string,
context: SearchContext
): Promise<SearchResult[]> {
// Create a safe execution context with required libraries
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
// Create the search function from script content
// The script should define a function that returns a Promise<SearchResult[]>
const searchFunction = new AsyncFunction(
'query',
'siteUrl',
'useProxy',
'axios',
'cheerio',
'proxyConfig',
`
${scriptContent}
// Call the search function defined in the script
if (typeof search === 'function') {
return await search(query, siteUrl, useProxy, axios, cheerio, proxyConfig);
} else {
throw new Error('Search function not defined in script');
}
`
);
// Execute the function with timeout
const timeoutPromise = new Promise<SearchResult[]>((_, reject) =>
setTimeout(() => reject(new Error('Search timeout')), 30000)
);
const searchPromise = searchFunction(
context.query,
context.siteUrl,
context.useProxy,
context.axios,
context.cheerio,
context.proxyConfig
);
return Promise.race([searchPromise, timeoutPromise]);
}
/**
* Get absolute path to search script
*/
function getScriptPath(scriptName: string): string {
const userDataPath = app.getPath('userData');
const scriptsDir = path.join(userDataPath, 'search-scripts');
// Check in user data directory first
let scriptPath = path.join(scriptsDir, scriptName);
if (fs.existsSync(scriptPath)) {
return scriptPath;
}
// Fallback to default scripts in app directory
scriptPath = path.join(__dirname, '../../search-scripts', scriptName);
if (fs.existsSync(scriptPath)) {
return scriptPath;
}
return scriptPath; // Return anyway, will be checked by caller
}
/**
* List all available search scripts
*/
export function listAvailableScripts(): string[] {
const scripts: string[] = [];
// Check default scripts
const defaultScriptsDir = path.join(__dirname, '../../search-scripts');
if (fs.existsSync(defaultScriptsDir)) {
const files = fs.readdirSync(defaultScriptsDir);
scripts.push(...files.filter((f) => f.endsWith('.js')));
}
// Check user scripts
const userDataPath = app.getPath('userData');
const userScriptsDir = path.join(userDataPath, 'search-scripts');
if (fs.existsSync(userScriptsDir)) {
const files = fs.readdirSync(userScriptsDir);
scripts.push(...files.filter((f) => f.endsWith('.js') && !scripts.includes(f)));
}
return scripts;
}

151
src/main/tabs.ts Normal file
View File

@@ -0,0 +1,151 @@
import { BrowserView, BrowserWindow } from 'electron';
import { ActiveTab } from '../shared/types';
import { v4 as uuidv4 } from 'uuid';
export class TabManager {
private tabs: Map<string, { tab: ActiveTab; view: BrowserView }> = new Map();
private activeTabId: string | null = null;
createTab(url: string, mainWindow: BrowserWindow): ActiveTab {
const tabId = uuidv4();
const view = new BrowserView({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
allowRunningInsecureContent: false,
},
});
const tab: ActiveTab = {
id: tabId,
title: 'Loading...',
url,
isActive: false,
createdAt: new Date().toISOString(),
};
// Load URL
view.webContents.loadURL(url);
// Update title when page loads
view.webContents.on('page-title-updated', (_, title) => {
tab.title = title;
});
// Update favicon
view.webContents.on('page-favicon-updated', (_, favicons) => {
if (favicons.length > 0) {
tab.favicon = favicons[0];
}
});
this.tabs.set(tabId, { tab, view });
// If this is the first tab, activate it
if (this.tabs.size === 1) {
this.activateTab(tabId, mainWindow);
}
return tab;
}
activateTab(tabId: string, mainWindow: BrowserWindow): boolean {
const tabData = this.tabs.get(tabId);
if (!tabData) {
return false;
}
// Deactivate previous tab
if (this.activeTabId) {
const prevTab = this.tabs.get(this.activeTabId);
if (prevTab) {
prevTab.tab.isActive = false;
mainWindow.removeBrowserView(prevTab.view);
}
}
// Activate new tab
tabData.tab.isActive = true;
this.activeTabId = tabId;
mainWindow.addBrowserView(tabData.view);
const bounds = mainWindow.getContentBounds();
// Reserve space for top navigation (e.g., 60px)
tabData.view.setBounds({
x: 0,
y: 60,
width: bounds.width,
height: bounds.height - 60,
});
// Auto-resize
mainWindow.on('resize', () => {
const newBounds = mainWindow.getContentBounds();
tabData.view.setBounds({
x: 0,
y: 60,
width: newBounds.width,
height: newBounds.height - 60,
});
});
return true;
}
hideActiveTab(mainWindow: BrowserWindow): boolean {
if (!this.activeTabId) {
return false;
}
const tabData = this.tabs.get(this.activeTabId);
if (!tabData) {
return false;
}
// Hide the active tab by removing the BrowserView
tabData.tab.isActive = false;
mainWindow.removeBrowserView(tabData.view);
this.activeTabId = null;
return true;
}
closeTab(tabId: string): boolean {
const tabData = this.tabs.get(tabId);
if (!tabData) {
return false;
}
// Destroy the view
(tabData.view.webContents as any).destroy();
this.tabs.delete(tabId);
// If this was the active tab, activate another one
if (this.activeTabId === tabId) {
this.activeTabId = null;
const nextTab = Array.from(this.tabs.keys())[0];
if (nextTab) {
// Will be activated when user selects it
}
}
return true;
}
getTabs(): ActiveTab[] {
return Array.from(this.tabs.values()).map((data) => data.tab);
}
getActiveTab(): ActiveTab | null {
if (!this.activeTabId) {
return null;
}
const tabData = this.tabs.get(this.activeTabId);
return tabData ? tabData.tab : null;
}
}

99
src/main/updater.ts Normal file
View File

@@ -0,0 +1,99 @@
import axios from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import { app, shell } from 'electron';
import { VersionInfo } from '../shared/types';
import { APP_VERSION } from '../shared/constants';
import { ConfigManager } from './config';
export class UpdaterManager {
private configManager: ConfigManager;
constructor(configManager: ConfigManager) {
this.configManager = configManager;
}
async checkForUpdates(): Promise<VersionInfo> {
try {
const settings = await this.configManager.getConfig();
const serverUrl = (settings as any).serverUrl || 'https://your-server.com/api';
const endpoint = `${serverUrl}/version/check`;
const response = await axios.get<VersionInfo>(endpoint, {
params: {
currentVersion: APP_VERSION,
platform: process.platform,
},
timeout: 10000,
});
return response.data;
} catch (error: any) {
console.error('Error checking for updates:', error.message);
// Return no update available if check fails
return {
latestVersion: APP_VERSION,
updateAvailable: false,
downloadUrl: '',
changelog: '',
releaseDate: '',
mandatory: false,
};
}
}
async downloadUpdate(downloadUrl: string): Promise<string> {
try {
const downloadsPath = app.getPath('downloads');
const fileName = path.basename(new URL(downloadUrl).pathname);
const filePath = path.join(downloadsPath, fileName);
const response = await axios.get(downloadUrl, {
responseType: 'stream',
timeout: 300000, // 5 minutes
});
const writer = fs.createWriteStream(filePath);
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', () => resolve(filePath));
writer.on('error', reject);
});
} catch (error: any) {
console.error('Error downloading update:', error.message);
throw new Error(`Failed to download update: ${error.message}`);
}
}
async installUpdate(installerPath: string): Promise<void> {
try {
// Open the installer
await shell.openPath(installerPath);
// Close the app after a short delay
setTimeout(() => {
app.quit();
}, 1000);
} catch (error: any) {
console.error('Error installing update:', error.message);
throw new Error(`Failed to install update: ${error.message}`);
}
}
compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const part1 = parts1[i] || 0;
const part2 = parts2[i] || 0;
if (part1 > part2) return 1;
if (part1 < part2) return -1;
}
return 0;
}
}

42
src/renderer/App.tsx Normal file
View File

@@ -0,0 +1,42 @@
import React, { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
import Home from './pages/Home';
import Bookmarks from './pages/Bookmarks';
import ActiveTabs from './pages/ActiveTabs';
import Settings from './pages/Settings';
import UpdateNotification from './components/UpdateNotification';
import { VersionInfo } from '../shared/types';
function App() {
const [updateInfo, setUpdateInfo] = useState<VersionInfo | null>(null);
useEffect(() => {
// Listen for update notifications
window.electronAPI.onUpdateAvailable((versionInfo) => {
setUpdateInfo(versionInfo);
});
}, []);
return (
<BrowserRouter>
<Layout>
{updateInfo && updateInfo.updateAvailable && (
<UpdateNotification
versionInfo={updateInfo}
onClose={() => setUpdateInfo(null)}
/>
)}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/bookmarks" element={<Bookmarks />} />
<Route path="/tabs" element={<ActiveTabs />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Bookmark } from '../../shared/types';
import '../styles/BookmarkCard.css';
interface BookmarkCardProps {
bookmark: Bookmark;
onOpen: () => void;
onDelete: () => void;
}
const BookmarkCard: React.FC<BookmarkCardProps> = ({
bookmark,
onOpen,
onDelete,
}) => {
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (confirm(`Удалить закладку "${bookmark.title}"?`)) {
onDelete();
}
};
return (
<div className="bookmark-card" onClick={onOpen}>
{bookmark.image && (
<img
src={bookmark.image}
alt={bookmark.title}
className="bookmark-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="bookmark-info">
<h3 className="bookmark-title">{bookmark.title}</h3>
<p className="bookmark-site">{bookmark.siteName}</p>
{bookmark.metadata?.year && (
<p className="bookmark-year">{bookmark.metadata.year}</p>
)}
{bookmark.metadata?.description && (
<p className="bookmark-description">{bookmark.metadata.description}</p>
)}
</div>
<button className="delete-button" onClick={handleDelete}>
</button>
</div>
);
};
export default BookmarkCard;

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import SearchBar from './SearchBar';
import '../styles/Layout.css';
interface LayoutProps {
children: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
const navigate = useNavigate();
const location = useLocation();
const isActive = (path: string) => location.pathname === path;
const handleBackClick = async () => {
await window.electronAPI.hideActiveTab();
};
return (
<div className="layout">
<div className="header">
<div className="header-top">
<button className="back-button" onClick={handleBackClick} title="Закрыть вкладку">
Назад
</button>
<h1 className="app-title">Media Center</h1>
<div className="search-container">
<SearchBar />
</div>
</div>
<nav className="nav-tabs">
<button
className={`nav-tab ${isActive('/') ? 'active' : ''}`}
onClick={() => navigate('/')}
>
Главная
</button>
<button
className={`nav-tab ${isActive('/bookmarks') ? 'active' : ''}`}
onClick={() => navigate('/bookmarks')}
>
Закладки
</button>
<button
className={`nav-tab ${isActive('/tabs') ? 'active' : ''}`}
onClick={() => navigate('/tabs')}
>
Активные вкладки
</button>
<button
className={`nav-tab ${isActive('/settings') ? 'active' : ''}`}
onClick={() => navigate('/settings')}
>
Настройки
</button>
</nav>
</div>
<main className="main-content">{children}</main>
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import SearchResults from './SearchResults';
import { SearchResultWithSource } from '../../shared/types';
import '../styles/SearchBar.css';
const SearchBar: React.FC = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResultWithSource[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
setIsSearching(true);
setShowResults(true);
try {
const searchResults = await window.electronAPI.searchAllSites(query);
setResults(searchResults);
} catch (error) {
console.error('Search error:', error);
setResults([]);
} finally {
setIsSearching(false);
}
};
const handleResultClick = async (result: SearchResultWithSource) => {
try {
await window.electronAPI.createTab(result.url);
setShowResults(false);
setQuery('');
setResults([]);
} catch (error) {
console.error('Error opening tab:', error);
}
};
return (
<div className="search-bar">
<form onSubmit={handleSearch} className="search-form">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск фильмов на всех сайтах..."
className="search-input"
disabled={isSearching}
/>
<button
type="submit"
className="search-button"
disabled={isSearching || !query.trim()}
>
{isSearching ? 'Поиск...' : 'Найти'}
</button>
</form>
{showResults && (
<SearchResults
results={results}
isSearching={isSearching}
onResultClick={handleResultClick}
onClose={() => setShowResults(false)}
/>
)}
</div>
);
};
export default SearchBar;

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { SearchResultWithSource } from '../../shared/types';
import '../styles/SearchResults.css';
interface SearchResultsProps {
results: SearchResultWithSource[];
isSearching: boolean;
onResultClick: (result: SearchResultWithSource) => void;
onClose: () => void;
}
const SearchResults: React.FC<SearchResultsProps> = ({
results,
isSearching,
onResultClick,
onClose,
}) => {
return (
<div className="search-results-overlay" onClick={onClose}>
<div className="search-results" onClick={(e) => e.stopPropagation()}>
<div className="search-results-header">
<h3>Результаты поиска</h3>
<button className="close-button" onClick={onClose}>
</button>
</div>
<div className="search-results-content">
{isSearching && (
<div className="search-loading">
<div className="spinner"></div>
<p>Поиск на всех сайтах...</p>
</div>
)}
{!isSearching && results.length === 0 && (
<div className="no-results">
<p>Ничего не найдено</p>
</div>
)}
{!isSearching && results.length > 0 && (
<div className="results-grid">
{results.map((result, index) => (
<div
key={`${result.source}-${index}`}
className="result-card"
onClick={() => onResultClick(result)}
>
{result.image && (
<img
src={result.image}
alt={result.name}
className="result-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="result-info">
<h4 className="result-title">{result.name}</h4>
<p className="result-source">{result.sourceName}</p>
{result.year && (
<p className="result-year">{result.year}</p>
)}
{result.description && (
<p className="result-description">{result.description}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default SearchResults;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { SiteConfig } from '../../shared/types';
import '../styles/SiteCard.css';
interface SiteCardProps {
site: SiteConfig;
onClick: () => void;
}
const SiteCard: React.FC<SiteCardProps> = ({ site, onClick }) => {
return (
<div className="site-card" onClick={onClick}>
<div className="site-card-header">
{site.logo && (
<img
src={site.logo}
alt={site.name}
className="site-logo"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<h3 className="site-name">{site.name}</h3>
</div>
<p className="site-url">{site.url}</p>
<div className="site-badges">
{site.useProxy && <span className="badge proxy-badge">Прокси</span>}
<span className="badge enabled-badge">Активен</span>
</div>
</div>
);
};
export default SiteCard;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { ActiveTab } from '../../shared/types';
import '../styles/TabCard.css';
interface TabCardProps {
tab: ActiveTab;
onActivate: () => void;
onClose: () => void;
}
const TabCard: React.FC<TabCardProps> = ({ tab, onActivate, onClose }) => {
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
onClose();
};
return (
<div
className={`tab-card ${tab.isActive ? 'active' : ''}`}
onClick={onActivate}
>
<div className="tab-favicon">
{tab.favicon ? (
<img src={tab.favicon} alt="" />
) : (
<div className="favicon-placeholder">🌐</div>
)}
</div>
<div className="tab-info">
<h3 className="tab-title">{tab.title || 'Loading...'}</h3>
<p className="tab-url">{tab.url}</p>
</div>
<button className="tab-close-button" onClick={handleClose}>
</button>
</div>
);
};
export default TabCard;

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { VersionInfo } from '../../shared/types';
import '../styles/UpdateNotification.css';
interface UpdateNotificationProps {
versionInfo: VersionInfo;
onClose: () => void;
}
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
versionInfo,
onClose,
}) => {
const [isDownloading, setIsDownloading] = useState(false);
const handleUpdate = async () => {
setIsDownloading(true);
try {
const installerPath = await window.electronAPI.downloadUpdate(
versionInfo.downloadUrl
);
alert(`Обновление загружено: ${installerPath}\риложение будет закрыто для установки.`);
// App will close automatically after opening installer
} catch (error) {
console.error('Error downloading update:', error);
alert('Ошибка при загрузке обновления');
setIsDownloading(false);
}
};
return (
<div className="update-notification">
<div className="update-content">
<h3>Доступно обновление!</h3>
<p className="version">
Версия <strong>{versionInfo.latestVersion}</strong>
</p>
<div className="changelog">
<h4>Что нового:</h4>
<pre>{versionInfo.changelog}</pre>
</div>
<div className="update-actions">
<button
className="button-primary"
onClick={handleUpdate}
disabled={isDownloading}
>
{isDownloading ? 'Загрузка...' : 'Обновить сейчас'}
</button>
<button className="button-secondary" onClick={onClose}>
Напомнить позже
</button>
</div>
</div>
</div>
);
};
export default UpdateNotification;

12
src/renderer/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Media Center</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

10
src/renderer/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import { ActiveTab } from '../../shared/types';
import TabCard from '../components/TabCard';
import '../styles/ActiveTabs.css';
const ActiveTabs: React.FC = () => {
const [tabs, setTabs] = useState<ActiveTab[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadTabs();
// Refresh tabs every 2 seconds
const interval = setInterval(loadTabs, 2000);
return () => clearInterval(interval);
}, []);
const loadTabs = async () => {
try {
const data = await window.electronAPI.getTabs();
setTabs(data);
} catch (error) {
console.error('Error loading tabs:', error);
} finally {
setLoading(false);
}
};
const handleActivate = async (tabId: string) => {
try {
await window.electronAPI.activateTab(tabId);
loadTabs(); // Refresh to show active state
} catch (error) {
console.error('Error activating tab:', error);
}
};
const handleClose = async (tabId: string) => {
try {
const success = await window.electronAPI.closeTab(tabId);
if (success) {
setTabs(tabs.filter((t) => t.id !== tabId));
}
} catch (error) {
console.error('Error closing tab:', error);
}
};
if (loading) {
return (
<div className="page-loading">
<div className="spinner"></div>
<p>Загрузка вкладок...</p>
</div>
);
}
return (
<div className="tabs-page">
<h2>Активные вкладки</h2>
<div className="tabs-list">
{tabs.map((tab) => (
<TabCard
key={tab.id}
tab={tab}
onActivate={() => handleActivate(tab.id)}
onClose={() => handleClose(tab.id)}
/>
))}
</div>
{tabs.length === 0 && !loading && (
<div className="empty-state">
<p>Нет открытых вкладок</p>
<p className="hint">
Откройте сайт из главной страницы или выполните поиск
</p>
</div>
)}
</div>
);
};
export default ActiveTabs;

View File

@@ -0,0 +1,106 @@
import React, { useEffect, useState } from 'react';
import { Bookmark } from '../../shared/types';
import BookmarkCard from '../components/BookmarkCard';
import '../styles/Bookmarks.css';
const Bookmarks: React.FC = () => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<string>('all');
useEffect(() => {
loadBookmarks();
}, []);
const loadBookmarks = async () => {
try {
const data = await window.electronAPI.getBookmarks();
setBookmarks(data);
} catch (error) {
console.error('Error loading bookmarks:', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
try {
const success = await window.electronAPI.removeBookmark(id);
if (success) {
setBookmarks(bookmarks.filter((b) => b.id !== id));
}
} catch (error) {
console.error('Error deleting bookmark:', error);
}
};
const handleOpen = async (url: string) => {
try {
await window.electronAPI.createTab(url);
} catch (error) {
console.error('Error opening bookmark:', error);
}
};
// Get unique sites for filter
const sites = Array.from(new Set(bookmarks.map((b) => b.siteId)));
const filteredBookmarks =
filter === 'all'
? bookmarks
: bookmarks.filter((b) => b.siteId === filter);
if (loading) {
return (
<div className="page-loading">
<div className="spinner"></div>
<p>Загрузка закладок...</p>
</div>
);
}
return (
<div className="bookmarks-page">
<div className="bookmarks-header">
<h2>Закладки</h2>
<div className="bookmarks-filter">
<label>Фильтр:</label>
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все сайты</option>
{sites.map((siteId) => {
const siteName =
bookmarks.find((b) => b.siteId === siteId)?.siteName || siteId;
return (
<option key={siteId} value={siteId}>
{siteName}
</option>
);
})}
</select>
</div>
</div>
<div className="bookmarks-grid">
{filteredBookmarks.map((bookmark) => (
<BookmarkCard
key={bookmark.id}
bookmark={bookmark}
onOpen={() => handleOpen(bookmark.url)}
onDelete={() => handleDelete(bookmark.id)}
/>
))}
</div>
{filteredBookmarks.length === 0 && !loading && (
<div className="empty-state">
<p>Нет сохраненных закладок</p>
<p className="hint">
Добавьте закладки через поиск или при просмотре фильмов
</p>
</div>
)}
</div>
);
};
export default Bookmarks;

View File

@@ -0,0 +1,66 @@
import React, { useEffect, useState } from 'react';
import { SiteConfig } from '../../shared/types';
import SiteCard from '../components/SiteCard';
import '../styles/Home.css';
const Home: React.FC = () => {
const [sites, setSites] = useState<SiteConfig[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSites();
}, []);
const loadSites = async () => {
try {
const config = await window.electronAPI.getConfig();
setSites(config.sites.filter((site: SiteConfig) => site.enabled));
} catch (error) {
console.error('Error loading sites:', error);
} finally {
setLoading(false);
}
};
const handleSiteClick = async (site: SiteConfig) => {
try {
await window.electronAPI.createTab(site.url);
} catch (error) {
console.error('Error opening site:', error);
}
};
if (loading) {
return (
<div className="page-loading">
<div className="spinner"></div>
<p>Загрузка...</p>
</div>
);
}
return (
<div className="home-page">
<h2>Доступные сайты</h2>
<div className="sites-grid">
{sites.map((site) => (
<SiteCard
key={site.id}
site={site}
onClick={() => handleSiteClick(site)}
/>
))}
</div>
{sites.length === 0 && (
<div className="empty-state">
<p>Нет доступных сайтов</p>
<p className="hint">
Добавьте сайты через настройки или обновите конфигурацию
</p>
</div>
)}
</div>
);
};
export default Home;

View File

@@ -0,0 +1,231 @@
import React, { useEffect, useState } from 'react';
import { AppSettings, ProxyStatus } from '../../shared/types';
import '../styles/Settings.css';
const Settings: React.FC = () => {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [proxyStatus, setProxyStatus] = useState<ProxyStatus | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [updating, setUpdating] = useState(false);
const [message, setMessage] = useState<{
type: 'success' | 'error';
text: string;
} | null>(null);
useEffect(() => {
loadSettings();
loadProxyStatus();
}, []);
const loadSettings = async () => {
try {
const data = await window.electronAPI.getSettings();
setSettings(data);
} catch (error) {
console.error('Error loading settings:', error);
} finally {
setLoading(false);
}
};
const loadProxyStatus = async () => {
try {
const status = await window.electronAPI.getProxyStatus();
setProxyStatus(status);
} catch (error) {
console.error('Error loading proxy status:', error);
}
};
const handleSave = async () => {
if (!settings) return;
setSaving(true);
try {
await window.electronAPI.saveSettings(settings);
showMessage('success', 'Настройки сохранены');
} catch (error) {
console.error('Error saving settings:', error);
showMessage('error', 'Ошибка при сохранении настроек');
} finally {
setSaving(false);
}
};
const handleUpdateConfig = async () => {
setUpdating(true);
try {
await window.electronAPI.updateConfig();
showMessage('success', 'Конфигурация обновлена успешно');
} catch (error: any) {
console.error('Error updating config:', error);
showMessage('error', `Ошибка обновления: ${error.message}`);
} finally {
setUpdating(false);
}
};
const handleProxyToggle = async () => {
try {
if (proxyStatus?.isRunning) {
const newStatus = await window.electronAPI.stopProxy();
setProxyStatus(newStatus);
showMessage('success', 'Прокси остановлен');
} else {
const newStatus = await window.electronAPI.startProxy();
setProxyStatus(newStatus);
showMessage('success', 'Прокси запущен');
}
} catch (error) {
console.error('Error toggling proxy:', error);
showMessage('error', 'Ошибка управления прокси');
}
};
const handleCheckUpdate = async () => {
try {
const versionInfo = await window.electronAPI.checkVersion();
if (versionInfo.updateAvailable) {
showMessage(
'success',
`Доступна новая версия: ${versionInfo.latestVersion}`
);
} else {
showMessage('success', 'У вас установлена последняя версия');
}
} catch (error) {
console.error('Error checking version:', error);
showMessage('error', 'Ошибка проверки обновлений');
}
};
const showMessage = (type: 'success' | 'error', text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage(null), 5000);
};
if (loading || !settings) {
return (
<div className="page-loading">
<div className="spinner"></div>
<p>Загрузка настроек...</p>
</div>
);
}
return (
<div className="settings-page">
<h2>Настройки</h2>
{message && (
<div className={`message message-${message.type}`}>{message.text}</div>
)}
<div className="settings-section">
<h3>Конфигурация сайтов</h3>
<div className="setting-item">
<label>URL сервера конфигураций:</label>
<input
type="text"
value={settings.configServerUrl}
onChange={(e) =>
setSettings({ ...settings, configServerUrl: e.target.value })
}
/>
</div>
{settings.lastConfigUpdate && (
<p className="hint">
Последнее обновление: {new Date(settings.lastConfigUpdate).toLocaleString()}
</p>
)}
<button
className="button-primary"
onClick={handleUpdateConfig}
disabled={updating}
>
{updating ? 'Обновление...' : 'Обновить конфигурацию'}
</button>
</div>
<div className="settings-section">
<h3>Прокси</h3>
<div className="setting-item">
<label>Статус прокси:</label>
<div className="proxy-status">
<span
className={`status-indicator ${
proxyStatus?.isRunning ? 'running' : 'stopped'
}`}
>
{proxyStatus?.isRunning ? '● Запущен' : '○ Остановлен'}
</span>
{proxyStatus?.port && <span>Порт: {proxyStatus.port}</span>}
</div>
</div>
<div className="setting-item">
<label>
<input
type="checkbox"
checked={settings.proxyAutoStart}
onChange={(e) =>
setSettings({ ...settings, proxyAutoStart: e.target.checked })
}
/>
Автозапуск прокси при старте приложения
</label>
</div>
<button className="button-secondary" onClick={handleProxyToggle}>
{proxyStatus?.isRunning ? 'Остановить прокси' : 'Запустить прокси'}
</button>
</div>
<div className="settings-section">
<h3>Интерфейс</h3>
<div className="setting-item">
<label>Тема:</label>
<select
value={settings.theme}
onChange={(e) =>
setSettings({ ...settings, theme: e.target.value as 'light' | 'dark' })
}
>
<option value="dark">Темная</option>
<option value="light">Светлая</option>
</select>
</div>
<div className="setting-item">
<label>Язык:</label>
<select
value={settings.language}
onChange={(e) =>
setSettings({ ...settings, language: e.target.value as 'ru' | 'en' })
}
>
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</div>
</div>
<div className="settings-section">
<h3>Обновления</h3>
<button className="button-secondary" onClick={handleCheckUpdate}>
Проверить обновления
</button>
</div>
<div className="settings-actions">
<button
className="button-primary"
onClick={handleSave}
disabled={saving}
>
{saving ? 'Сохранение...' : 'Сохранить настройки'}
</button>
</div>
</div>
);
};
export default Settings;

View File

@@ -0,0 +1,10 @@
.tabs-page h2 {
margin: 0 0 1.5rem 0;
font-size: 1.5rem;
}
.tabs-list {
display: flex;
flex-direction: column;
gap: 1rem;
}

View File

@@ -0,0 +1,81 @@
.bookmark-card {
position: relative;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
}
.bookmark-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px var(--shadow);
}
.bookmark-image {
width: 100%;
height: 300px;
object-fit: cover;
background-color: var(--bg-tertiary);
}
.bookmark-info {
padding: 1rem;
}
.bookmark-title {
font-size: 1.1rem;
font-weight: 500;
margin: 0 0 0.5rem 0;
line-height: 1.3;
}
.bookmark-site {
color: var(--accent);
font-size: 0.85rem;
margin: 0 0 0.25rem 0;
}
.bookmark-year {
color: var(--text-secondary);
font-size: 0.85rem;
margin: 0 0 0.5rem 0;
}
.bookmark-description {
color: var(--text-secondary);
font-size: 0.85rem;
line-height: 1.4;
margin: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.delete-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background-color: rgba(244, 67, 54, 0.9);
color: white;
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.bookmark-card:hover .delete-button {
opacity: 1;
}
.delete-button:hover {
background-color: rgba(244, 67, 54, 1);
}

View File

@@ -0,0 +1,33 @@
.bookmarks-page h2 {
margin: 0 0 1.5rem 0;
font-size: 1.5rem;
}
.bookmarks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.bookmarks-filter {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bookmarks-filter label {
font-size: 0.9rem;
color: var(--text-secondary);
}
.bookmarks-filter select {
width: auto;
min-width: 150px;
}
.bookmarks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}

View File

@@ -0,0 +1,10 @@
.home-page h2 {
margin: 0 0 1.5rem 0;
font-size: 1.5rem;
}
.sites-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}

View File

@@ -0,0 +1,77 @@
.layout {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
}
.header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 1rem;
flex-shrink: 0;
}
.header-top {
display: flex;
align-items: center;
gap: 2rem;
margin-bottom: 1rem;
}
.back-button {
background-color: var(--accent);
color: white;
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 500;
transition: all 0.2s;
white-space: nowrap;
}
.back-button:hover {
background-color: var(--accent-hover);
transform: translateX(-2px);
}
.app-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
}
.search-container {
flex: 1;
max-width: 600px;
}
.nav-tabs {
display: flex;
gap: 0.5rem;
}
.nav-tab {
background: transparent;
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: 6px 6px 0 0;
transition: all 0.2s;
}
.nav-tab:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.nav-tab.active {
background-color: var(--bg-primary);
color: var(--accent);
font-weight: 500;
}
.main-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
}

View File

@@ -0,0 +1,29 @@
.search-bar {
position: relative;
width: 100%;
}
.search-form {
display: flex;
gap: 0.5rem;
width: 100%;
}
.search-input {
flex: 1;
}
.search-button {
background-color: var(--accent);
color: white;
padding: 0.6rem 1.5rem;
white-space: nowrap;
}
.search-button:hover:not(:disabled) {
background-color: var(--accent-hover);
}
.search-button:disabled {
opacity: 0.6;
}

View File

@@ -0,0 +1,131 @@
.search-results-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
}
.search-results {
background-color: var(--bg-secondary);
border-radius: 12px;
width: 100%;
max-width: 1200px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px var(--shadow);
}
.search-results-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.search-results-header h3 {
margin: 0;
font-size: 1.3rem;
}
.close-button {
background: transparent;
color: var(--text-secondary);
font-size: 1.5rem;
padding: 0.25rem 0.5rem;
line-height: 1;
}
.close-button:hover {
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
.search-results-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.search-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
}
.no-results {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.result-card {
background-color: var(--bg-tertiary);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.result-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px var(--shadow);
}
.result-image {
width: 100%;
height: 350px;
object-fit: cover;
background-color: var(--bg-secondary);
}
.result-info {
padding: 1rem;
}
.result-title {
font-size: 1rem;
font-weight: 500;
margin: 0 0 0.5rem 0;
line-height: 1.4;
}
.result-source {
font-size: 0.85rem;
color: var(--accent);
margin: 0 0 0.25rem 0;
}
.result-year {
font-size: 0.85rem;
color: var(--text-secondary);
margin: 0 0 0.5rem 0;
}
.result-description {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.4;
margin: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}

View File

@@ -0,0 +1,86 @@
.settings-page h2 {
margin: 0 0 2rem 0;
font-size: 1.5rem;
}
.message {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.message-success {
background-color: rgba(76, 175, 80, 0.2);
color: var(--success);
border: 1px solid var(--success);
}
.message-error {
background-color: rgba(244, 67, 54, 0.2);
color: var(--error);
border: 1px solid var(--error);
}
.settings-section {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.settings-section h3 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
}
.setting-item {
margin-bottom: 1rem;
}
.setting-item:last-child {
margin-bottom: 1.5rem;
}
.setting-item label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.setting-item label input[type='checkbox'] {
margin-right: 0.5rem;
}
.hint {
font-size: 0.85rem;
color: var(--text-secondary);
margin: 0.5rem 0 1rem 0;
}
.proxy-status {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background-color: var(--bg-tertiary);
border-radius: 6px;
}
.status-indicator {
font-size: 0.9rem;
font-weight: 500;
}
.status-indicator.running {
color: var(--success);
}
.status-indicator.stopped {
color: var(--text-secondary);
}
.settings-actions {
margin-top: 2rem;
}

View File

@@ -0,0 +1,63 @@
.site-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
cursor: pointer;
transition: all 0.2s;
}
.site-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px var(--shadow);
border-color: var(--accent);
}
.site-card-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.site-logo {
width: 32px;
height: 32px;
border-radius: 4px;
}
.site-name {
font-size: 1.2rem;
font-weight: 500;
margin: 0;
}
.site-url {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0 0 1rem 0;
word-break: break-all;
}
.site-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
}
.proxy-badge {
background-color: rgba(74, 158, 255, 0.2);
color: var(--accent);
}
.enabled-badge {
background-color: rgba(76, 175, 80, 0.2);
color: var(--success);
}

View File

@@ -0,0 +1,84 @@
.tab-card {
display: flex;
align-items: center;
gap: 1rem;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.tab-card:hover {
border-color: var(--accent);
box-shadow: 0 2px 8px var(--shadow);
}
.tab-card.active {
background-color: var(--bg-tertiary);
border-color: var(--accent);
}
.tab-favicon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.tab-favicon img {
width: 100%;
height: 100%;
border-radius: 4px;
}
.favicon-placeholder {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.tab-info {
flex: 1;
min-width: 0;
}
.tab-title {
font-size: 1rem;
font-weight: 500;
margin: 0 0 0.25rem 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-url {
font-size: 0.85rem;
color: var(--text-secondary);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-close-button {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tab-close-button:hover {
background-color: var(--error);
color: white;
}

View File

@@ -0,0 +1,69 @@
.update-notification {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 2000;
width: 400px;
background-color: var(--bg-secondary);
border: 1px solid var(--accent);
border-radius: 12px;
box-shadow: 0 8px 24px var(--shadow);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(120%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.update-content {
padding: 1.5rem;
}
.update-content h3 {
margin: 0 0 1rem 0;
color: var(--accent);
}
.version {
font-size: 0.9rem;
margin-bottom: 1rem;
}
.changelog {
background-color: var(--bg-tertiary);
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
max-height: 200px;
overflow-y: auto;
}
.changelog h4 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
}
.changelog pre {
margin: 0;
white-space: pre-wrap;
font-family: inherit;
font-size: 0.85rem;
line-height: 1.5;
color: var(--text-secondary);
}
.update-actions {
display: flex;
gap: 0.75rem;
}
.update-actions button {
flex: 1;
}

View File

@@ -0,0 +1,158 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2a2a2a;
--bg-tertiary: #3a3a3a;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--accent: #4a9eff;
--accent-hover: #3a8eef;
--success: #4caf50;
--error: #f44336;
--border: #404040;
--shadow: rgba(0, 0, 0, 0.3);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
}
#root {
width: 100vw;
height: 100vh;
}
/* Common classes */
.page-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid var(--bg-tertiary);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
text-align: center;
color: var(--text-secondary);
}
.empty-state p {
margin: 0.5rem 0;
}
.empty-state .hint {
font-size: 0.9rem;
opacity: 0.7;
}
/* Buttons */
button {
font-family: inherit;
cursor: pointer;
border: none;
border-radius: 6px;
padding: 0.6rem 1.2rem;
font-size: 0.9rem;
transition: all 0.2s;
}
.button-primary {
background-color: var(--accent);
color: white;
}
.button-primary:hover:not(:disabled) {
background-color: var(--accent-hover);
}
.button-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.button-secondary:hover:not(:disabled) {
background-color: var(--bg-secondary);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Inputs */
input[type='text'],
input[type='url'],
select {
background-color: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.6rem;
font-size: 0.9rem;
font-family: inherit;
width: 100%;
}
input[type='text']:focus,
input[type='url']:focus,
select:focus {
outline: none;
border-color: var(--accent);
}
input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border);
}

20
src/shared/constants.ts Normal file
View File

@@ -0,0 +1,20 @@
export const APP_VERSION = '1.0.1';
export const DEFAULT_CONFIG_SERVER_URL = 'https://your-server.com/api';
export const DEFAULT_PROXY_PORT = 10808;
export const DEFAULT_SETTINGS = {
configServerUrl: DEFAULT_CONFIG_SERVER_URL,
proxyAutoStart: true,
theme: 'dark' as const,
language: 'ru' as const,
};
export const INVISIBLE_MAN_CLI_PATH = 'invisibleManXRay'; // Path to CLI executable
export const DB_PATHS = {
BOOKMARKS: 'bookmarks.db',
SETTINGS: 'settings.db',
CONFIG: 'config.json',
};

121
src/shared/types.ts Normal file
View File

@@ -0,0 +1,121 @@
// Site Configuration Types
export interface SiteConfig {
id: string;
name: string;
url: string;
logo: string;
enabled: boolean;
useProxy: boolean;
searchScript: string; // Path to custom search script file
}
export interface ConfigData {
version: string;
lastUpdated: string;
sites: SiteConfig[];
}
// Search Results
export interface SearchResult {
name: string; // Movie/Series name
url: string; // Link to the movie page
image?: string; // Poster image
year?: string;
description?: string;
rating?: string;
}
export interface SearchResultWithSource extends SearchResult {
source: string; // Site ID
sourceName: string; // Site Name
}
// Bookmark Types
export interface Bookmark {
id: string;
title: string;
url: string;
image?: string;
siteId: string;
siteName: string;
createdAt: string;
metadata?: {
year?: string;
description?: string;
rating?: string;
};
}
// Active Tab Types
export interface ActiveTab {
id: string;
title: string;
url: string;
favicon?: string;
isActive: boolean;
createdAt: string;
}
// Version Check Types
export interface VersionInfo {
latestVersion: string;
updateAvailable: boolean;
downloadUrl: string;
changelog: string;
releaseDate: string;
mandatory: boolean;
}
// Proxy Types
export interface ProxyStatus {
isRunning: boolean;
port?: number;
error?: string;
}
// Settings Types
export interface AppSettings {
configServerUrl: string;
lastConfigUpdate?: string;
proxyAutoStart: boolean;
theme: 'light' | 'dark';
language: 'ru' | 'en';
downloadFolder?: string;
}
// IPC Channels
export enum IPC_CHANNELS {
// Config
GET_CONFIG = 'config:get',
UPDATE_CONFIG = 'config:update',
SAVE_CONFIG = 'config:save',
// Search
SEARCH_ALL_SITES = 'search:all',
SEARCH_SITE = 'search:site',
// Bookmarks
GET_BOOKMARKS = 'bookmarks:get',
ADD_BOOKMARK = 'bookmarks:add',
REMOVE_BOOKMARK = 'bookmarks:remove',
// Tabs
GET_TABS = 'tabs:get',
CREATE_TAB = 'tabs:create',
CLOSE_TAB = 'tabs:close',
ACTIVATE_TAB = 'tabs:activate',
HIDE_ACTIVE_TAB = 'tabs:hide',
// Proxy
GET_PROXY_STATUS = 'proxy:status',
START_PROXY = 'proxy:start',
STOP_PROXY = 'proxy:stop',
// Version
CHECK_VERSION = 'version:check',
DOWNLOAD_UPDATE = 'version:download',
// Settings
GET_SETTINGS = 'settings:get',
SAVE_SETTINGS = 'settings:save',
}

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/renderer"],
"references": [{ "path": "./tsconfig.main.json" }]
}

19
tsconfig.main.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist/main",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": false,
"jsx": "preserve"
},
"include": ["src/main/**/*", "src/shared/**/*"],
"exclude": ["node_modules", "src/renderer", "dist"]
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
base: './',
root: resolve(__dirname, 'src/renderer'),
publicDir: resolve(__dirname, 'public'),
build: {
outDir: resolve(__dirname, 'dist/renderer'),
emptyOutDir: true,
},
resolve: {
alias: {
'@': resolve(__dirname, 'src/renderer'),
'@shared': resolve(__dirname, 'src/shared'),
},
},
server: {
port: 3000,
},
});