Sync Home

This commit is contained in:
2026-08-14 14:02:51 -07:00
commit c08223bd57
21 changed files with 7730 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# Dependencies
node_modules/
# Build outputs
dist/
dist-build/
temp-validation-extract/
update-payload.zip
manifest.json
integrity-report.json
validation-report.json
# Encrypted files (build-time only)
config/encrypted-credentials.json
# Sensitive files
credentials.json
.env
*.key
*.pem
# OS files
.DS_Store
Thumbs.db
desktop.ini
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Electron
out/
# Temporary files
*.tmp
*.temp
download-temp.zip

176
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,176 @@
stages:
- build
- release
build:
stage: build
tags:
- saas-windows-medium-amd64
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- build-pipeline.js
- package.json
- config/**/*
- main.js
- service-manager.js
- patcher.js
- credential-manager.js
- crypto-utils.js
- update-client.js
- .gitlab-ci.yml
variables:
GITLAB_TOKEN: $VYLA_GITLAB_TOKEN
PLAYER_TMDB_KEY: $TMDB_KEY
PLAYER_API_KEY: $API_KEY
STREAM_API_TMDB_API_KEY: $TMDB_API_KEY
STREAM_API_GA_API_SECRET: $GA_API_SECRET
STREAM_API_GA_MEASUREMENT_ID: $GA_MEASUREMENT_ID
STREAM_API_TOKEN_SECRET: $TOKEN_SECRET
PLAYER_BASE_URL: $BASE_URL
STREAM_API_PROXY_URL: $PROXY_URL
STREAM_API_PROXY_STREAMS: $PROXY_STREAMS
STREAM_API_ENABLE_DEBUG_ROUTE: $ENABLE_DEBUG_ROUTE
PLAYER_BYPASS_AUTH: $BYPASS_AUTH
STREAM_API_BYPASS_AUTH: $BYPASS_AUTH
AUTH_PROXY_VITE_BASE_URL: $AUTH_PROXY_BASE_URL
AUTH_PROXY_VITE_API_KEY: $AUTH_PROXY_API_KEY
AUTH_PROXY_DEV_BYPASS_AUTH: $DEV_BYPASS_AUTH
AUTH_PROXY_PROXY_VITE_API_KEY: $PROXY_VITE_API_KEY
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
script:
- npm ci
- |
$repos = @('frontend', 'player', 'stream-api', 'live-api-streampk', 'auth-proxy')
$last = @{}
if (Test-Path "last-shas.json") {
$last = Get-Content last-shas.json | ConvertFrom-Json -AsHashtable
}
$current = @{}
$changed = $false
foreach ($repo in $repos) {
$headers = @{
"PRIVATE-TOKEN" = "$env:GITLAB_TOKEN"
}
$uri = "https://gitlab.com/api/v4/projects/vyla-entertainment%2F$repo/repository/commits/main"
try {
$resp = Invoke-RestMethod -Uri $uri -Headers $headers
$sha = $resp.id
$current[$repo] = $sha
if (-not $last.ContainsKey($repo) -or $last[$repo] -ne $sha) {
Write-Host "Change detected in $repo (main): $sha"
$changed = $true
} else {
Write-Host "No change in $repo (main)"
}
} catch {
Write-Warning "Failed to fetch commit info for $repo. (Ensure token has api/read_repository scopes)."
}
}
$current | ConvertTo-Json | Set-Content last-shas.json
if ($env:CI_PIPELINE_SOURCE -ne "schedule") {
$changed = $true
}
if (-not $changed) {
Write-Host "No changes detected. Skipping build."
echo "CHANGED=false" >> build.env
exit 0
}
echo "CHANGED=true" >> build.env
- |
git config user.name "gitlab-ci[bot]"
git config user.email "gitlab-ci[bot]@gitlab.com"
git add last-shas.json
$gitStatus = git status --porcelain
if ($gitStatus) {
git remote set-url origin "https://oauth2:$env:GITLAB_TOKEN@gitlab.com/$env:CI_PROJECT_PATH.git"
git commit -m "chore: update last-known repo SHAs [skip ci]"
git push origin "HEAD:$env:CI_COMMIT_BRANCH"
}
- Write-Host "Starting secure build process..."
- node build-pipeline.js
- |
if (Test-Path "update-payload.zip") {
Write-Host "✓ Build artifact verified: update-payload.zip exists"
$size = (Get-Item update-payload.zip).Length
Write-Host " Size: $size bytes"
} else {
Write-Error "✗ Build artifact missing: update-payload.zip"
exit 1
}
if (Test-Path "manifest.json") {
Write-Host "✓ Manifest file verified"
Get-Content manifest.json | ConvertFrom-Json | ConvertTo-Json -Depth 10
} else {
Write-Error "✗ Manifest file missing"
exit 1
}
if (Test-Path "validation-report.json") {
Write-Host "✓ Validation report verified"
} else {
Write-Warning "⚠ Validation report missing (non-critical)"
}
- Write-Host "Packaging Electron application..."
- npm run dist
- echo "BUILD_VERSION=v$env:CI_PIPELINE_IID" >> build.env
artifacts:
reports:
dotenv: build.env
paths:
- update-payload.zip
- manifest.json
- validation-report.json
- dist/*.exe
- dist/*.exe.blockmap
expire_in: 30 days
release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
tags:
- saas-linux-small-amd64
needs:
- job: build
artifacts: true
rules:
- if: '$CHANGED == "true" && $CI_COMMIT_BRANCH == "main"'
script:
- echo "Creating GitLab Release $BUILD_VERSION"
release:
name: 'Vyla Home Release $BUILD_VERSION'
tag_name: '$BUILD_VERSION'
description: 'Automated release for build $BUILD_VERSION'
assets:
links:
- name: 'update-payload.zip'
url: '$CI_PROJECT_URL/-/jobs/artifacts/$CI_COMMIT_BRANCH/raw/update-payload.zip?job=build'
- name: 'manifest.json'
url: '$CI_PROJECT_URL/-/jobs/artifacts/$CI_COMMIT_BRANCH/raw/manifest.json?job=build'
- name: 'Download All Build Artifacts (including Windows Installer)'
url: '$CI_PROJECT_URL/-/jobs/artifacts/$CI_COMMIT_BRANCH/download?job=build'

29
README.md Normal file
View File

@@ -0,0 +1,29 @@
# Vyla Home
Vyla Home is a desktop version of Vyla, built to run entirely on your own computer instead of on [vyla.cc](https://vyla.cc).
## Why this exists
Running Vyla as a hosted website has been a constant source of stress. Hosting providers routinely block or throttle traffic coming from data centers, which is exactly where most affordable hosting lives. Every workaround for that involves paid infrastructure — residential proxies, dedicated IPs, premium tiers — that isn't realistic to keep funding long-term.
On top of that, there's the standing fear that comes with depending on someone else's infrastructure: a provider changes their policy, a server gets flagged, an account gets suspended, and everything just stops working with no warning. That uncertainty has genuinely cost some sleep. Waking up wondering if the whole project got wiped overnight isn't a sustainable way to build something.
Vyla Home is the answer to that. Instead of fighting hosting providers forever, the app runs locally on your own machine. No data center traffic to block. No hosting bills. No one else's finger on the switch.
## What it actually is
Vyla Home packages up the same player, streaming, and live-sports features as the website, wrapped in a small desktop app. When you open it:
- It grabs the latest version automatically
- It starts everything up locally on your computer
- The interface opens in its own window, just like the website did
Everything runs for you, on your machine, for your own personal use.
## What this is not
This isn't a public service or a way to redistribute anything. It's a personal media player for personal use — think of it as watching things you already have access to, through your own local player, instead of a public streaming platform. Nothing here is intended to be shared, resold, or run as a service for other people.
## Status
Might not be actively worked on. Expect various bugs — this is a solo project built to solve a real problem, not a polished commercial product.

400
build-pipeline.js Normal file
View File

@@ -0,0 +1,400 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const JavaScriptObfuscator = require('javascript-obfuscator');
const AdmZip = require('adm-zip');
const { execSync } = require('child_process');
const BUILD_DIR = path.join(__dirname, 'dist-build');
const REPOS = ['frontend', 'player', 'auth-proxy', 'stream-api', 'live-api-streampk'];
const TEMP_EXTRACT_DIR = path.join(__dirname, 'temp-validation-extract');
const GITLAB_GROUP = 'vyla-entertainment';
const REPO_CONFIG = {
'frontend': { name: 'frontend', branch: 'main' },
'player': { name: 'player', branch: 'main' },
'auth-proxy': { name: 'auth-proxy', branch: 'main' },
'stream-api': { name: 'stream-api', branch: 'main' },
'live-api-streampk': { name: 'live-api-streampk', branch: 'main' }
};
function getGitLabToken() {
const token = process.env.GITLAB_TOKEN || process.env.VYLA_GITLAB_TOKEN;
if (!token) {
throw new Error('GitLab token not found. Set GITLAB_TOKEN or VYLA_GITLAB_TOKEN environment variable.');
}
return token;
}
function clonePrivateRepo(repoConfig, targetDir) {
const token = getGitLabToken();
const repoUrl = `https://oauth2:${token}@gitlab.com/${GITLAB_GROUP}/${repoConfig.name}.git`;
try {
if (fs.existsSync(targetDir)) {
fs.rmSync(targetDir, { recursive: true, force: true });
}
execSync(`git clone --depth 1 --branch ${repoConfig.branch} ${repoUrl} ${targetDir}`, {
stdio: 'inherit',
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
});
} catch (error) {
throw new Error(`Failed to clone ${repoConfig.name}: ${error.message}`);
}
}
function cloneAllRepos() {
for (const [key, config] of Object.entries(REPO_CONFIG)) {
const targetDir = path.join(__dirname, key);
clonePrivateRepo(config, targetDir);
}
}
async function installDependencies() {
await Promise.all(REPOS.map(repo => installOneRepo(repo)));
}
function installOneRepo(repo) {
const repoDir = path.join(__dirname, repo);
const pkgJsonPath = path.join(repoDir, 'package.json');
if (!fs.existsSync(pkgJsonPath)) {
return;
}
try {
execSync('npm install --omit=dev', {
cwd: repoDir,
stdio: 'inherit'
});
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
const hasNativeDeps = pkg.dependencies && Object.keys(pkg.dependencies).some(dep =>
['bcrypt', 'sharp', 'sqlite3'].includes(dep)
);
if (hasNativeDeps) {
const electronVersion = JSON.parse(
fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')
).devDependencies.electron.replace(/^[\^~]/, '');
if (!/^\d+\.\d+\.\d+$/.test(electronVersion)) {
throw new Error(`Cannot resolve exact Electron version from "${electronVersion}"; pin an exact version in package.json devDependencies before building.`);
}
const rebuildBin = path.join(__dirname, 'node_modules', '.bin', process.platform === 'win32' ? 'electron-rebuild.cmd' : 'electron-rebuild');
execSync(`"${rebuildBin}" -f -v ${electronVersion} -m "${repoDir}"`, {
cwd: __dirname,
stdio: 'inherit'
});
}
} catch (error) {
throw new Error(`npm install failed for ${repo}: ${error.message}`);
}
}
function cleanBuildDir() {
if (fs.existsSync(BUILD_DIR)) {
fs.rmSync(BUILD_DIR, { recursive: true, force: true });
}
fs.mkdirSync(BUILD_DIR, { recursive: true });
}
function obfuscateFile(filePath, isEsm = false) {
const content = fs.readFileSync(filePath, 'utf8');
const result = JavaScriptObfuscator.obfuscate(content, {
compact: true,
controlFlowFlattening: false,
deadCodeInjection: false,
debugProtection: false,
numbersToExpressions: false,
simplify: true,
stringArray: true,
stringArrayThreshold: 0.4,
stringArrayEncoding: [],
module: isEsm
});
fs.writeFileSync(filePath, result.getObfuscatedCode(), 'utf8');
}
function encryptCredentialsFile() {
const CryptoUtils = require('./crypto-utils');
const credentialsTemplate = path.join(__dirname, 'config/credentials-template.json');
const encryptedOutput = path.join(__dirname, 'config/encrypted-credentials.json');
if (!fs.existsSync(credentialsTemplate)) {
return null;
}
try {
const credentials = JSON.parse(fs.readFileSync(credentialsTemplate, 'utf8'));
const hasPlaceholders = Object.values(credentials).some(service =>
Object.values(service).some(value =>
value && value.includes('YOUR_') && value.includes('_HERE')
)
);
if (hasPlaceholders) {
const envCredentials = {};
for (const [service, keys] of Object.entries(credentials)) {
envCredentials[service] = {};
for (const key of Object.keys(keys)) {
const envVarName = `${service.toUpperCase().replace(/-/g, '_')}_${key.toUpperCase()}`;
if (process.env[envVarName]) {
envCredentials[service][key] = process.env[envVarName];
}
}
}
if (Object.values(envCredentials).some(s => Object.keys(s).length > 0)) {
Object.assign(credentials, envCredentials);
}
}
const buildKey = CryptoUtils.deriveKey('vyla-home-build-key', 'vyla-home-payload-salt-v1');
const encryptedData = CryptoUtils.encryptObject(credentials, buildKey);
fs.writeFileSync(encryptedOutput, encryptedData, 'utf8');
return encryptedOutput;
} catch (error) {
throw error;
}
}
function collectJsFiles(src, dest, out) {
const files = fs.readdirSync(src);
fs.mkdirSync(dest, { recursive: true });
for (const file of files) {
const srcPath = path.join(src, file);
const destPath = path.join(dest, file);
const stat = fs.statSync(srcPath);
if (stat.isDirectory()) {
if (file === 'node_modules' || file === '.git' || file === '.github') continue;
collectJsFiles(srcPath, destPath, out);
} else if (stat.isFile()) {
fs.copyFileSync(srcPath, destPath);
if (path.extname(srcPath).toLowerCase() === '.js') {
out.push(destPath);
}
}
}
}
function processFolder(src, dest) {
const jsFiles = [];
collectJsFiles(src, dest, jsFiles);
let isEsm = false;
const pkgPath = path.join(src, 'package.json');
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (pkg.type === 'module') isEsm = true;
} catch (e) { }
}
for (const filePath of jsFiles) {
try {
obfuscateFile(filePath, isEsm);
} catch (e) {
}
}
}
function getFileChecksum(filePath) {
const hash = crypto.createHash('sha256');
const data = fs.readFileSync(filePath);
hash.update(data);
return hash.digest('hex');
}
function validatePayload(zipPath) {
if (fs.existsSync(TEMP_EXTRACT_DIR)) {
fs.rmSync(TEMP_EXTRACT_DIR, { recursive: true, force: true });
}
fs.mkdirSync(TEMP_EXTRACT_DIR, { recursive: true });
const zip = new AdmZip(zipPath);
zip.extractAllTo(TEMP_EXTRACT_DIR, true);
const requiredFiles = [
'player/index.html',
'auth-proxy/server.js',
'stream-api/server.js',
'live-api-streampk/server.js',
'frontend/index.html'
];
const validationResults = {
valid: true,
errors: [],
checksum: getFileChecksum(zipPath),
timestamp: new Date().toISOString()
};
for (const file of requiredFiles) {
const fullPath = path.join(TEMP_EXTRACT_DIR, file);
if (!fs.existsSync(fullPath)) {
validationResults.valid = false;
validationResults.errors.push(`Missing critical file: ${file}`);
}
}
const envPath = path.join(__dirname, 'config/env.json');
const replacementsPath = path.join(__dirname, 'config/replacements.json');
if (!fs.existsSync(envPath)) {
validationResults.valid = false;
validationResults.errors.push('Missing environment configuration: config/env.json');
} else {
try {
JSON.parse(fs.readFileSync(envPath, 'utf8'));
} catch (e) {
validationResults.valid = false;
validationResults.errors.push(`Invalid JSON format in config/env.json: ${e.message}`);
}
}
if (!fs.existsSync(replacementsPath)) {
validationResults.valid = false;
validationResults.errors.push('Missing replacements configuration: config/replacements.json');
} else {
try {
JSON.parse(fs.readFileSync(replacementsPath, 'utf8'));
} catch (e) {
validationResults.valid = false;
validationResults.errors.push(`Invalid JSON format in config/replacements.json: ${e.message}`);
}
}
fs.rmSync(TEMP_EXTRACT_DIR, { recursive: true, force: true });
fs.writeFileSync(path.join(__dirname, 'validation-report.json'), JSON.stringify(validationResults, null, 2), 'utf8');
if (!validationResults.valid) {
console.error("Payload validation failed with errors:", validationResults.errors);
process.exit(1);
}
return validationResults.checksum;
}
async function build() {
try {
cloneAllRepos();
} catch (error) {
console.error("Cloning failed:", error);
process.exit(1);
}
for (const repo of REPOS) {
const srcRepo = path.join(__dirname, repo);
if (!fs.existsSync(srcRepo)) {
console.error(`Repository directory missing: ${srcRepo}`);
process.exit(1);
}
}
try {
await installDependencies();
} catch (error) {
console.error("Dependency installation failed:", error);
process.exit(1);
}
let encryptedCredentialsPath = null;
try {
encryptedCredentialsPath = encryptCredentialsFile();
} catch (error) {
console.error("Credential encryption warning (non-fatal):", error);
}
cleanBuildDir();
for (const repo of REPOS) {
const srcRepo = path.join(__dirname, repo);
const destRepo = path.join(BUILD_DIR, repo);
processFolder(srcRepo, destRepo);
const srcModules = path.join(srcRepo, 'node_modules');
const destModules = path.join(destRepo, 'node_modules');
if (fs.existsSync(srcModules)) {
fs.cpSync(srcModules, destModules, {
recursive: true,
filter: (src) => !/[\\/](test|tests|docs|\.github|\.git)([\\/]|$)/.test(src) && !/\.(md|markdown|map|ts)$/i.test(src)
});
}
}
if (encryptedCredentialsPath && fs.existsSync(encryptedCredentialsPath)) {
const credentialsDest = path.join(BUILD_DIR, 'encrypted-credentials.json');
fs.copyFileSync(encryptedCredentialsPath, credentialsDest);
}
const configDir = path.join(BUILD_DIR, 'config');
fs.mkdirSync(configDir, { recursive: true });
const envConfig = path.join(__dirname, 'config/env.json');
const replacementsConfig = path.join(__dirname, 'config/replacements.json');
if (fs.existsSync(envConfig)) {
fs.copyFileSync(envConfig, path.join(configDir, 'env.json'));
}
if (fs.existsSync(replacementsConfig)) {
fs.copyFileSync(replacementsConfig, path.join(configDir, 'replacements.json'));
}
const zip = new AdmZip();
for (const repo of REPOS) {
const destRepo = path.join(BUILD_DIR, repo);
zip.addLocalFolder(destRepo, repo);
}
zip.addLocalFolder(configDir, 'config');
if (fs.existsSync(path.join(BUILD_DIR, 'encrypted-credentials.json'))) {
zip.addFile('encrypted-credentials.json', fs.readFileSync(path.join(BUILD_DIR, 'encrypted-credentials.json')));
}
const zipPath = path.join(__dirname, 'update-payload.zip');
zip.writeZip(zipPath);
const checksum = validatePayload(zipPath);
let wrapperVersion = '2.0.0';
try {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
wrapperVersion = pkg.version || '2.0.0';
} catch (e) { }
const manifest = {
version: wrapperVersion,
sha256: checksum,
timestamp: new Date().toISOString(),
hasEncryptedCredentials: !!encryptedCredentialsPath
};
fs.writeFileSync(path.join(__dirname, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
for (const repo of REPOS) {
const repoPath = path.join(__dirname, repo);
if (fs.existsSync(repoPath)) {
fs.rmSync(repoPath, { recursive: true, force: true });
}
}
}
build().catch((error) => {
console.error("Build process encountered an unhandled exception:", error);
process.exit(1);
});

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

6
build/logo.txt Normal file
View File

@@ -0,0 +1,6 @@
██████╗ ██╗ ██╗██╗ ██╗██╗ █████╗ ███████╗███╗ ██╗████████╗███████╗██████╗ ████████╗ █████╗ ██╗███╗ ██╗███╗ ███╗███████╗███╗ ██╗████████╗
██╔═══██╗██║ ██║╚██╗ ██╔╝██║ ██╔══██╗ ██╔════╝████╗ ██║╚══██╔══╝██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██║████╗ ██║████╗ ████║██╔════╝████╗ ██║╚══██╔══╝
██║██╗██║██║ ██║ ╚████╔╝ ██║ ███████║ █████╗ ██╔██╗ ██║ ██║ █████╗ ██████╔╝ ██║ ███████║██║██╔██╗ ██║██╔████╔██║█████╗ ██╔██╗ ██║ ██║
██║██║██║╚██╗ ██╔╝ ╚██╔╝ ██║ ██╔══██║ ██╔══╝ ██║╚██╗██║ ██║ ██╔══╝ ██╔══██╗ ██║ ██╔══██║██║██║╚██╗██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║ ██║
╚█║████╔╝ ╚████╔╝ ██║ ███████╗██║ ██║ ███████╗██║ ╚████║ ██║ ███████╗██║ ██║ ██║ ██║ ██║██║██║ ╚████║██║ ╚═╝ ██║███████╗██║ ╚████║ ██║
╚╝╚═══╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝

View File

@@ -0,0 +1,16 @@
{
"player": {
"TMDB_KEY": "YOUR_TMDB_KEY_HERE",
"API_KEY": "YOUR_API_KEY_HERE"
},
"auth-proxy": {
"VITE_API_KEY": "YOUR_VITE_API_KEY_HERE",
"PROXY_VITE_API_KEY": "YOUR_PROXY_VITE_API_KEY_HERE"
},
"stream-api": {
"TMDB_API_KEY": "YOUR_TMDB_API_KEY_HERE",
"GA_MEASUREMENT_ID": "YOUR_GA_MEASUREMENT_ID_HERE",
"GA_API_SECRET": "YOUR_GA_API_SECRET_HERE",
"TOKEN_SECRET": "YOUR_TOKEN_SECRET_HERE"
}
}

22
config/env.json Normal file
View File

@@ -0,0 +1,22 @@
{
"player": {
"PORT": "3001",
"BASE_URL": "http://localhost:7860",
"BYPASS_AUTH": "true"
},
"auth-proxy": {
"PORT": "3000",
"VITE_BASE_URL": "http://localhost:7860",
"DEV_BYPASS_AUTH": "true"
},
"stream-api": {
"PORT": "7860",
"PROXY_URL": "https://txt.primebox.workers.dev",
"PROXY_STREAMS": "true",
"ENABLE_DEBUG_ROUTE": "true",
"BYPASS_AUTH": "true"
},
"live-api-streampk": {
"PORT": "5000"
}
}

54
config/replacements.json Normal file
View File

@@ -0,0 +1,54 @@
{
"frontend": [
{
"find": "https://player.vyla.cc",
"replace": "http://localhost:3001"
},
{
"find": "http://player.vyla.cc",
"replace": "http://localhost:3001"
},
{
"find": "https://api.vyla.cc",
"replace": "http://localhost:7860"
},
{
"find": "https://live.vyla.cc",
"replace": "http://localhost:5000"
},
{
"find": "https://streamed.pk/api",
"replace": "http://localhost:5000"
},
{
"find": "https://auth.vyla.cc",
"replace": "http://localhost:3000"
}
],
"player": [
{
"find": "https://live.vyla.cc",
"replace": "http://localhost:5000"
},
{
"find": "https://api.vyla.cc",
"replace": "http://localhost:7860"
},
{
"find": "https://auth.vyla.cc",
"replace": "http://localhost:3000"
},
{
"find": "https://player.vyla.cc",
"replace": "http://localhost:4890"
}
],
"auth-proxy": [
{
"find": "https://player.vyla.cc",
"replace": "http://localhost:3001"
}
],
"stream-api": [],
"live-api-streampk": []
}

109
credential-manager.js Normal file
View File

@@ -0,0 +1,109 @@
const fs = require('fs');
const path = require('path');
const CryptoUtils = require('./crypto-utils');
class CredentialManager {
constructor(appDataPath) {
this.appDataPath = appDataPath;
this.credentialsPath = path.join(appDataPath, 'vyla-credentials-store.json');
this.machineKey = CryptoUtils.getMachineKey(appDataPath);
this.encryptionKey = CryptoUtils.deriveKey(this.machineKey);
this.credentials = {};
}
loadCredentials() {
try {
if (fs.existsSync(this.credentialsPath)) {
const encryptedData = fs.readFileSync(this.credentialsPath, 'utf8');
this.credentials = CryptoUtils.decryptObject(encryptedData, this.encryptionKey);
} else {
this.credentials = {};
}
} catch (error) {
this.credentials = {};
}
}
saveCredentials() {
try {
const encryptedData = CryptoUtils.encryptObject(this.credentials, this.encryptionKey);
fs.writeFileSync(this.credentialsPath, encryptedData, 'utf8');
} catch (error) {
throw error;
}
}
getCredential(service, key) {
if (!this.credentials[service]) {
return null;
}
return this.credentials[service][key] || null;
}
setCredential(service, key, value) {
if (!this.credentials[service]) {
this.credentials[service] = {};
}
this.credentials[service][key] = value;
this.saveCredentials();
}
getServiceCredentials(service) {
return this.credentials[service] || {};
}
setServiceCredentials(service, credentials) {
this.credentials[service] = { ...credentials };
this.saveCredentials();
}
hasServiceCredentials(service) {
return !!this.credentials[service] && Object.keys(this.credentials[service]).length > 0;
}
deleteServiceCredentials(service) {
delete this.credentials[service];
this.saveCredentials();
}
initializeFromConfig(config) {
let initialized = false;
for (const [service, keys] of Object.entries(config)) {
if (!this.hasServiceCredentials(service)) {
this.setServiceCredentials(service, keys);
initialized = true;
}
}
return initialized;
}
getAllEnvVars() {
const envVars = {};
for (const [service, credentials] of Object.entries(this.credentials)) {
envVars[service] = { ...credentials };
}
return envVars;
}
verifyIntegrity() {
try {
if (!fs.existsSync(this.credentialsPath)) {
return false;
}
const encryptedData = fs.readFileSync(this.credentialsPath, 'utf8');
CryptoUtils.decryptObject(encryptedData, this.encryptionKey);
return true;
} catch (error) {
return false;
}
}
resetCredentials() {
this.credentials = {};
if (fs.existsSync(this.credentialsPath)) {
fs.unlinkSync(this.credentialsPath);
}
}
}
module.exports = CredentialManager;

144
crypto-utils.js Normal file
View File

@@ -0,0 +1,144 @@
const crypto = require('crypto');
const os = require('os');
const { execSync } = require('child_process');
class CryptoUtils {
static getMachineKey(cacheDir) {
const path = require('path');
const fs = require('fs');
const cachePath = cacheDir ? path.join(cacheDir, '.machine-key-cache') : null;
if (cachePath && fs.existsSync(cachePath)) {
try {
const cached = fs.readFileSync(cachePath, 'utf8').trim();
if (cached) return cached;
} catch (e) {
}
}
let key;
try {
const machineId = [
os.hostname(),
os.platform(),
os.arch(),
os.cpus()[0].model,
this.getMachineId(),
process.env.COMPUTERNAME || '',
process.env.USERNAME || ''
].join('|');
const hash = crypto.createHash('sha256');
hash.update(machineId);
key = hash.digest('hex');
} catch (error) {
const fallback = crypto.createHash('sha256');
fallback.update(os.hostname() + Date.now());
key = fallback.digest('hex');
}
if (cachePath) {
try {
fs.writeFileSync(cachePath, key, 'utf8');
} catch (e) {
}
}
return key;
}
static getMachineId() {
if (os.platform() === 'win32') {
try {
return execSync('wmic csproduct get uuid').toString().split('\n')[1].trim();
} catch (e) {
}
try {
const out = execSync('reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid').toString();
const match = out.match(/MachineGuid\s+REG_SZ\s+([^\r\n]+)/);
if (match) return match[1].trim();
} catch (e) {
}
return 'unknown-machine';
} else if (os.platform() === 'darwin') {
try {
return execSync('ioreg -rd1 -c IOPlatformExpertDevice | awk -F\'"\' \'/IOPlatformUUID/{print $4}\'').toString().trim();
} catch (e) {
return 'unknown-machine';
}
} else {
try {
return execSync('cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null').toString().trim();
} catch (e) {
return 'unknown-machine';
}
}
}
static deriveKey(machineKey, salt = 'vyla-home-salt') {
const hash = crypto.createHash('sha256');
hash.update(machineKey + salt);
return hash.digest();
}
static encrypt(plaintext, key) {
try {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
} catch (error) {
throw new Error(`Encryption failed: ${error.message}`);
}
}
static decrypt(encryptedData, key) {
try {
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
throw new Error(`Decryption failed: ${error.message}`);
}
}
static encryptObject(obj, key) {
const json = JSON.stringify(obj);
return this.encrypt(json, key);
}
static decryptObject(encryptedData, key) {
const json = this.decrypt(encryptedData, key);
return JSON.parse(json);
}
static hashData(data) {
const hash = crypto.createHash('sha256');
hash.update(data);
return hash.digest('hex');
}
static verifyHash(data, expectedHash) {
const actualHash = this.hashData(data);
return actualHash === expectedHash;
}
}
module.exports = CryptoUtils;

179
github-client.js Normal file
View File

@@ -0,0 +1,179 @@
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const REPOS = {
frontend: { branch: 'main' },
player: { branch: 'main' },
'stream-api': { branch: 'main' },
'live-api-streampk': { branch: 'main' },
'auth-proxy': { branch: 'main' }
};
const GITLAB_GROUP = 'vyla-entertainment';
class GitlabClient {
constructor(appDataPath, credentialManager) {
this.appDataPath = appDataPath;
this.credentialManager = credentialManager;
}
getToken() {
return this.credentialManager.getCredential('gitlab', 'TOKEN') || null;
}
setToken(token) {
this.credentialManager.setCredential('gitlab', 'TOKEN', token);
}
hasToken() {
return !!this.getToken();
}
buildRepoUrl(repoName) {
const token = this.getToken();
if (token) {
return `https://oauth2:${token}@gitlab.com/${GITLAB_GROUP}/${repoName}.git`;
}
return `https://gitlab.com/${GITLAB_GROUP}/${repoName}.git`;
}
repoDir(repoName) {
return path.join(this.appDataPath, repoName);
}
isRepoCloned(repoName) {
const gitDir = path.join(this.repoDir(repoName), '.git');
return fs.existsSync(gitDir);
}
allReposCloned() {
return Object.keys(REPOS).every((name) => this.isRepoCloned(name));
}
cloneRepo(repoName, onLog) {
const { branch } = REPOS[repoName];
const targetDir = this.repoDir(repoName);
const url = this.buildRepoUrl(repoName);
if (fs.existsSync(targetDir)) {
fs.rmSync(targetDir, { recursive: true, force: true });
}
if (onLog) onLog(`Cloning ${repoName}...`);
const result = spawnSync('git', ['clone', '--depth', '1', '--branch', branch, url, targetDir], {
stdio: 'pipe',
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
});
if (result.status !== 0) {
const stderr = result.stderr ? result.stderr.toString() : 'unknown error';
throw new Error(`Failed to clone ${repoName}: ${stderr}`);
}
}
cloneAll(onLog) {
for (const repoName of Object.keys(REPOS)) {
this.cloneRepo(repoName, onLog);
}
}
installDependencies(repoName, onLog) {
const repoDir = this.repoDir(repoName);
const pkgJsonPath = path.join(repoDir, 'package.json');
if (!fs.existsSync(pkgJsonPath)) {
return;
}
if (onLog) onLog(`Installing dependencies for ${repoName}...`);
const result = spawnSync('npm', ['install', '--omit=dev'], {
cwd: repoDir,
stdio: 'pipe',
shell: process.platform === 'win32'
});
if (result.status !== 0) {
const stderr = result.stderr ? result.stderr.toString() : 'unknown error';
throw new Error(`npm install failed for ${repoName}: ${stderr}`);
}
}
installAllDependencies(onLog) {
for (const repoName of Object.keys(REPOS)) {
this.installDependencies(repoName, onLog);
}
}
getLocalHead(repoName) {
const repoDir = this.repoDir(repoName);
try {
return execSync('git rev-parse HEAD', { cwd: repoDir }).toString().trim();
} catch (e) {
return null;
}
}
getRemoteHead(repoName) {
const { branch } = REPOS[repoName];
const repoDir = this.repoDir(repoName);
const url = this.buildRepoUrl(repoName);
try {
const output = execSync(`git ls-remote "${url}" refs/heads/${branch}`, { cwd: repoDir }).toString().trim();
return output.split('\t')[0] || null;
} catch (e) {
return null;
}
}
checkForUpdates() {
const outdated = [];
for (const repoName of Object.keys(REPOS)) {
const local = this.getLocalHead(repoName);
const remote = this.getRemoteHead(repoName);
if (local && remote && local !== remote) {
outdated.push(repoName);
}
}
return outdated;
}
updateRepo(repoName, onLog) {
const { branch } = REPOS[repoName];
const repoDir = this.repoDir(repoName);
if (onLog) onLog(`Updating ${repoName}...`);
const fetchResult = spawnSync('git', ['fetch', 'origin', branch], {
cwd: repoDir,
stdio: 'pipe',
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
});
if (fetchResult.status !== 0) {
const stderr = fetchResult.stderr ? fetchResult.stderr.toString() : 'unknown error';
throw new Error(`Failed to fetch ${repoName}: ${stderr}`);
}
const resetResult = spawnSync('git', ['reset', '--hard', `origin/${branch}`], {
cwd: repoDir,
stdio: 'pipe'
});
if (resetResult.status !== 0) {
const stderr = resetResult.stderr ? resetResult.stderr.toString() : 'unknown error';
throw new Error(`Failed to reset ${repoName}: ${stderr}`);
}
}
updateAll(repoNames, onLog) {
for (const repoName of repoNames) {
this.updateRepo(repoName, onLog);
this.installDependencies(repoName, onLog);
}
}
}
module.exports = GitlabClient

7
last-shas.json Normal file
View File

@@ -0,0 +1,7 @@
{
"stream-api": "5eb845865e6633511b2f68a0e1615b2391ddca6e",
"live-api-streampk": "6f2f3932360624b473c55becb7a5354b9a0fa804",
"auth-proxy": "7660a5a252554af55bf84da219b63a76322d8b85",
"player": "4b3a4a6523dbf019abe8f9ec02e689e5d4e3b672",
"frontend": "ecab7ccd9fc2011bb78ac37f5b2a478ff25d162d"
}

664
main.js Normal file
View File

@@ -0,0 +1,664 @@
const { app, BrowserWindow, ipcMain, Menu } = require('electron');
const path = require('path');
const fs = require('fs');
const express = require('express');
const ServiceManager = require('./service-manager');
const Patcher = require('./patcher');
const UpdateClient = require('./update-client');
let mainWindow;
let serviceManager;
let expressApp;
let expressServer;
let playerExpressApp;
let playerExpressServer;
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
const APPDATA_DIR = path.join(app.getPath('userData'), 'vyla-home-files');
fs.mkdirSync(APPDATA_DIR, { recursive: true });
const envConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'config/env.json'), 'utf8'));
const replacementsConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'config/replacements.json'), 'utf8'));
const updateClient = new UpdateClient(APPDATA_DIR);
process.on('unhandledRejection', (reason) => {
const message = reason && reason.message ? reason.message : String(reason);
fs.appendFileSync(path.join(APPDATA_DIR, 'crash.log'), `[${new Date().toISOString()}] UNHANDLED REJECTION: ${message}\n`, 'utf8');
});
process.on('uncaughtException', (err) => {
fs.appendFileSync(path.join(APPDATA_DIR, 'crash.log'), `[${new Date().toISOString()}] UNCAUGHT EXCEPTION: ${err.message}\n`, 'utf8');
});
async function createWindow() {
const asciiLogo = fs.readFileSync(path.join(__dirname, 'build', 'logo.txt'), 'utf8');
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
title: 'Vyla Home',
icon: path.join(__dirname, 'build', 'icon.ico'),
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 40
},
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
Menu.setApplicationMenu(null);
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
mainWindow.loadURL(url);
return { action: 'deny' };
});
mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vyla Home</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif;
}
body {
-webkit-app-region: no-drag;
}
.titlebar-drag {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 40px;
-webkit-app-region: drag;
z-index: 10;
}
button, a, input, .no-drag {
-webkit-app-region: no-drag;
}
canvas {
position: fixed;
inset: 0;
display: block;
width: 100vw;
height: 100vh;
}
.overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
pointer-events: none;
}
#ascii-logo {
font-family: "Cascadia Mono", "Consolas", monospace;
font-size: 11px;
line-height: 1.15;
color: rgba(255,255,255,.75);
white-space: pre;
margin: 0;
user-select: none;
text-shadow: 0 4px 12px rgba(0,0,0,.6);
transform: scale(0.4);
opacity: 0;
animation: logoIn 1.1s cubic-bezier(.16,1,.3,1) forwards;
}
#status {
font-size: 10px;
font-weight: 500;
letter-spacing: .08em;
text-transform: uppercase;
color: rgba(255,255,255,.4);
opacity: 0;
animation: statusIn .6s ease forwards .7s;
}
@keyframes logoIn {
0% {
transform: scale(0.4);
opacity: 0;
filter: blur(6px);
}
60% {
opacity: 1;
filter: blur(0);
}
100% {
transform: scale(1);
opacity: 1;
filter: blur(0);
}
}
@keyframes statusIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body>
<div class="titlebar-drag"></div>
<canvas id="c"></canvas>
<div class="overlay">
<pre id="ascii-logo">${asciiLogo.replace(/`/g, '\\`').replace(/\$/g, '\\$')}</pre>
</div>
<script>
const CHARS = ' .:-=+*#%@';
const FONT_SIZE = 50;
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let CW, CH;
let cols, rows;
let t = 0;
let mouseX = -9999;
let mouseY = -9999;
let lastMouseMove = 0;
let mouseStrength = 0;
const FADE_TIME = 250;
const SMOOTHING = 0.08;
function initFont() {
ctx.font = FONT_SIZE + 'px "Courier New", monospace';
CW = Math.ceil(ctx.measureText('M').width);
CH = FONT_SIZE + 2;
ctx.textBaseline = 'top';
}
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
initFont();
cols = Math.ceil(canvas.width / CW) + 1;
rows = Math.ceil(canvas.height / CH) + 1;
}
window.addEventListener('resize', resize);
window.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
lastMouseMove = performance.now();
});
function plasma(cx, cy, tt) {
const x = cx + Math.sin(cy * 3 + tt * 0.7) * 0.07;
const y = cy + Math.cos(cx * 3 + tt * 0.5) * 0.07;
let v =
Math.sin(x * 10 + tt) +
Math.sin(y * 10 + tt) +
Math.sin((x + y) * 7 + tt) +
Math.sin(Math.sqrt(x * x + y * y) * 12 - tt * 1.2) +
Math.sin(x * 6 - tt * 0.8) +
Math.sin(y * 8 + tt * 0.9);
return (v / 6 + 1) / 2;
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
t += 0.03;
const elapsed = performance.now() - lastMouseMove;
const targetStrength = Math.max(0, 1 - elapsed / FADE_TIME);
mouseStrength += (targetStrength - mouseStrength) * SMOOTHING;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * CW;
const y = row * CH;
const dx = x - mouseX;
const dy = y - mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
const force = (7000 / (dist * dist + 1500)) * mouseStrength;
const offsetX = dx * force;
const offsetY = dy * force;
const v = plasma(col / cols, row / rows, t);
const brightness = Math.round(v * 60);
const charIdx = Math.floor(v * (CHARS.length - 0.001));
const ch = CHARS[charIdx];
ctx.fillStyle = 'rgb(' + brightness + ',' + brightness + ',' + brightness + ')';
ctx.fillText(ch, x + offsetX, y + offsetY);
}
}
requestAnimationFrame(render);
}
resize();
render();
</script>
</body>
</html>
`));
const patcher = new Patcher(APPDATA_DIR);
serviceManager = new ServiceManager(APPDATA_DIR, envConfig);
const showUpdatePrompt = (currentVersion, newVersion) => {
return new Promise((resolve) => {
ipcMain.removeAllListeners('update-choice');
ipcMain.once('update-choice', (event, choice) => {
resolve(choice);
});
mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Update Available</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif;
}
body {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.container {
max-width: 480px;
text-align: center;
}
.icon {
width: 38px;
height: 38px;
margin: 0 auto 22px;
color: #0a84ff;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 600;
letter-spacing: -.02em;
}
p {
margin: 14px 0 28px;
color: rgba(255,255,255,.55);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.buttons {
display: flex;
gap: 10px;
justify-content: center;
}
button {
appearance: none;
border: 0;
border-radius: 8px;
padding: 11px 20px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: opacity .15s ease, transform .15s ease;
min-width: 120px;
}
button:hover:not(:disabled) {
opacity: .85;
}
button:active:not(:disabled) {
transform: scale(.97);
}
button:disabled {
cursor: default;
opacity: .35;
}
.primary {
background: #fff;
color: #000;
}
.secondary {
background: rgba(255,255,255,.1);
color: #fff;
}
</style>
</head>
<body>
<div class="container">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<h1>Update Available</h1>
<p>You're running version ${currentVersion}. Version ${newVersion} is available.\nInstalling will fetch the latest build.</p>
<div class="buttons">
<button id="skipBtn" class="secondary" onclick="handleChoice('skip')">
Skip For Now
</button>
<button id="installBtn" class="primary" onclick="handleChoice('install')">
Install Update
</button>
</div>
<script>
function handleChoice(choice) {
document.getElementById('skipBtn').disabled = true;
document.getElementById('installBtn').disabled = true;
window.vylaHome.sendUpdateChoice(choice);
}
</script>
</body>
</html>
`));
});
};
const handleFatalError = (errMessage) => {
mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Startup Error</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif;
}
body {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.container {
max-width: 480px;
text-align: center;
}
.icon {
width: 38px;
height: 38px;
margin: 0 auto 22px;
color: #ff453a;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 600;
letter-spacing: -.02em;
}
p {
margin: 14px 0 28px;
color: rgba(255,255,255,.55);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
button {
appearance: none;
border: 0;
border-radius: 999px;
background: #fff;
color: #000;
padding: 10px 18px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: opacity .2s ease;
}
button:hover {
opacity: .82;
}
button:active {
opacity: .65;
}
</style>
</head>
<body>
<div class="container">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="7" x2="12" y2="13"/>
<circle cx="12" cy="17" r="1"/>
</svg>
<h1>Couldn't start Vyla Home</h1>
<p>${errMessage}</p>
<button onclick="location.reload()">
Try Again
</button>
</div>
</body>
</html>
`));
};
const installBuild = async (release) => {
const tempZipPath = path.join(APPDATA_DIR, 'download-temp.zip');
let attempts = 0;
let downloadSuccess = false;
while (attempts < 3 && !downloadSuccess) {
attempts++;
try {
await updateClient.downloadUpdate(release, tempZipPath, (downloaded, total) => {
const percent = Math.round((downloaded / total) * 100);
});
if (!patcher.verifyChecksum(tempZipPath, release.sha256)) {
throw new Error('SHA-256 checksum verification failed.');
}
patcher.purgeOldCode();
patcher.extractZipPayload(tempZipPath);
if (!patcher.verifyExtraction()) {
throw new Error('Extraction verification failed: critical startup files missing.');
}
patcher.applyPatches(replacementsConfig);
patcher.bridgeCredentials(serviceManager.credentialManager);
patcher.saveCurrentVersion(release.version);
fs.unlinkSync(tempZipPath);
downloadSuccess = true;
} catch (err) {
if (fs.existsSync(tempZipPath)) fs.unlinkSync(tempZipPath);
if (attempts >= 3) {
throw new Error(`Failed to download and verify build after 3 attempts: ${err.message}`);
}
await new Promise(r => setTimeout(r, 2000));
}
}
};
try {
const isFirstRun = !patcher.verifyExtraction();
const localVersion = patcher.getCurrentVersion();
const release = await updateClient.checkForUpdates();
if (isFirstRun) {
await installBuild(release);
} else if (release.version !== localVersion) {
const choice = await showUpdatePrompt(localVersion, release.version);
if (choice === 'install') {
await installBuild(release);
}
}
if (!patcher.verifyExtraction()) {
throw new Error('No application files found. Check your internet connection and try again.');
}
serviceManager.credentialManager.loadCredentials();
await serviceManager.startServices([
{ name: 'auth-proxy', relativeDir: 'auth-proxy' },
{ name: 'stream-api', relativeDir: 'stream-api' },
{ name: 'live-api-streampk', relativeDir: 'live-api-streampk' }
]);
const playerDir = path.join(APPDATA_DIR, 'player');
playerExpressApp = express();
playerExpressApp.use(express.static(playerDir, { maxAge: '1d', etag: true }));
playerExpressApp.get('*', (req, res) => {
res.sendFile(path.join(playerDir, 'index.html'));
});
playerExpressServer = playerExpressApp.listen(3001);
await serviceManager.waitForPorts([3000, 3001, 7860, 5000]);
const healthChecks = [
{ port: 3001, name: 'player' },
{ port: 3000, name: 'auth-proxy' },
{ port: 7860, name: 'stream-api' },
{ port: 5000, name: 'live-api-streampk' }
];
for (const { port, name } of healthChecks) {
try {
await serviceManager.checkHealth(port, '/');
} catch (e) {
serviceManager.logToFile(name, `Health check on / did not pass, continuing anyway: ${e.message}`);
}
}
const frontendDir = path.join(APPDATA_DIR, 'frontend');
expressApp = express();
expressApp.use(express.static(frontendDir, { maxAge: '1d', etag: true }));
expressApp.get('*', (req, res) => {
res.sendFile(path.join(frontendDir, 'index.html'));
});
const FRONTEND_PORT = 4890;
expressServer = expressApp.listen(FRONTEND_PORT, () => {
mainWindow.loadURL(`http://localhost:${FRONTEND_PORT}`);
});
} catch (error) {
if (serviceManager) {
await serviceManager.stopAll().catch(() => { });
}
handleFatalError(error.message);
}
}
if (gotLock) {
app.whenReady().then(createWindow);
}
app.on('window-all-closed', async () => {
if (expressServer) expressServer.close();
if (playerExpressServer) playerExpressServer.close();
if (serviceManager) {
await serviceManager.stopAll();
}
if (process.platform !== 'darwin') {
app.quit();
}
});

5281
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "vyla-home",
"version": "1.0.0",
"description": "Vyla Home Desktop App Launcher",
"main": "main.js",
"scripts": {
"start": "electron .",
"pack": "electron-builder --dir",
"dist": "electron-builder",
"build": "node build-pipeline.js"
},
"dependencies": {
"@vyla-entertainment/sdk": "^1.5.0",
"adm-zip": "^0.6.0",
"audit": "^0.0.6",
"express": "^4.19.2"
},
"devDependencies": {
"@electron/rebuild": "^4.2.0",
"electron": "^42.7.0",
"electron-builder": "^26.15.3",
"javascript-obfuscator": "^4.1.0"
},
"build": {
"appId": "com.vyla.home",
"productName": "Vyla Home",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"preload.js",
"service-manager.js",
"patcher.js",
"crypto-utils.js",
"credential-manager.js",
"update-client.js",
"gitlab-client.js",
"config/**/*",
"build/icon.ico",
"build/logo.txt",
"package.json"
],
"win": {
"target": "nsis",
"icon": "build/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"installerIcon": "build/icon.ico",
"uninstallerIcon": "build/icon.ico",
"installerHeaderIcon": "build/icon.ico"
}
}
}

144
patcher.js Normal file
View File

@@ -0,0 +1,144 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const AdmZip = require('adm-zip');
const CryptoUtils = require('./crypto-utils');
class Patcher {
constructor(appDataPath) {
this.appDataPath = appDataPath;
}
getCurrentVersion() {
const versionFile = path.join(this.appDataPath, 'version.json');
if (fs.existsSync(versionFile)) {
try {
return JSON.parse(fs.readFileSync(versionFile, 'utf8')).version;
} catch (e) {
return '0.0.0';
}
}
return '0.0.0';
}
bridgeCredentials(credentialManager) {
const encryptedPath = path.join(this.appDataPath, 'encrypted-credentials.json');
if (!fs.existsSync(encryptedPath)) {
return false;
}
const buildKey = CryptoUtils.deriveKey('vyla-home-build-key', 'vyla-home-payload-salt-v1');
const encryptedData = fs.readFileSync(encryptedPath, 'utf8');
const credentials = CryptoUtils.decryptObject(encryptedData, buildKey);
for (const [service, keys] of Object.entries(credentials)) {
credentialManager.setServiceCredentials(service, keys);
}
fs.unlinkSync(encryptedPath);
return true;
}
saveCurrentVersion(version) {
const versionFile = path.join(this.appDataPath, 'version.json');
fs.mkdirSync(this.appDataPath, { recursive: true });
fs.writeFileSync(versionFile, JSON.stringify({ version }, null, 2), 'utf8');
}
purgeOldCode() {
const dirs = ['frontend', 'player', 'stream-api', 'live-api-streampk'];
for (const dir of dirs) {
const fullPath = path.join(this.appDataPath, dir);
if (fs.existsSync(fullPath)) {
fs.rmSync(fullPath, { recursive: true, force: true });
}
}
}
extractZipPayload(zipPath) {
const zip = new AdmZip(zipPath);
zip.extractAllTo(this.appDataPath, true);
}
verifyChecksum(zipPath, expectedSha256) {
if (!expectedSha256) return true;
const hash = crypto.createHash('sha256');
const data = fs.readFileSync(zipPath);
hash.update(data);
const calculated = hash.digest('hex');
return calculated === expectedSha256;
}
verifyExtraction() {
const requiredFiles = [
'player/index.html',
'auth-proxy/server.js',
'stream-api/server.js',
'live-api-streampk/server.js',
'frontend/index.html'
];
for (const file of requiredFiles) {
const fullPath = path.join(this.appDataPath, file);
if (!fs.existsSync(fullPath)) {
return false;
}
const stat = fs.statSync(fullPath);
if (stat.size === 0) {
return false;
}
const sampleSize = Math.min(stat.size, 4096);
const fd = fs.openSync(fullPath, 'r');
const buffer = Buffer.alloc(sampleSize);
fs.readSync(fd, buffer, 0, sampleSize, 0);
fs.closeSync(fd);
if (buffer.every(byte => byte === 0)) {
return false;
}
}
return true;
}
processDirectory(dir, replacementList) {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
if (file === 'node_modules' || file === '.git') continue;
this.processDirectory(fullPath, replacementList);
} else if (stat.isFile()) {
const ext = path.extname(fullPath).toLowerCase();
if (['.js', '.html', '.css', '.json', '.xml', '.txt'].includes(ext)) {
let content = fs.readFileSync(fullPath, 'utf8');
let modified = false;
for (const rep of replacementList) {
if (content.includes(rep.find)) {
content = content.split(rep.find).join(rep.replace);
modified = true;
}
}
if (modified) {
fs.writeFileSync(fullPath, content, 'utf8');
}
}
}
}
}
applyPatches(replacements) {
for (const [componentName, replacementList] of Object.entries(replacements)) {
if (!replacementList || replacementList.length === 0) continue;
const componentDir = path.join(this.appDataPath, componentName);
if (!fs.existsSync(componentDir)) continue;
this.processDirectory(componentDir, replacementList);
}
}
}
module.exports = Patcher;

5
preload.js Normal file
View File

@@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('vylaHome', {
sendUpdateChoice: (choice) => ipcRenderer.send('update-choice', choice)
});

170
service-manager.js Normal file
View File

@@ -0,0 +1,170 @@
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const http = require('http');
const CredentialManager = require('./credential-manager');
class ServiceManager {
constructor(appDataPath, envConfig) {
this.appDataPath = appDataPath;
this.envConfig = envConfig;
this.credentialManager = new CredentialManager(appDataPath);
this.processes = {};
this.restartCounts = {};
this.stableTimers = {};
this.isStopping = false;
this.logsDir = path.join(appDataPath, 'logs');
fs.mkdirSync(this.logsDir, { recursive: true });
}
logToFile(name, line) {
try {
const logPath = path.join(this.logsDir, `${name}.log`);
const timestamp = new Date().toISOString();
fs.appendFileSync(logPath, `[${timestamp}] ${line}\n`, 'utf8');
} catch (e) {
}
}
async startService(name, relativeDir, startScript = 'server.js') {
if (this.processes[name]) {
return;
}
const serviceDir = path.join(this.appDataPath, relativeDir);
const scriptPath = path.join(serviceDir, startScript);
if (!fs.existsSync(scriptPath)) {
throw new Error(`Service script not found: ${scriptPath}`);
}
const env = {
...process.env,
...(this.envConfig[name] || {}),
...(this.credentialManager.getServiceCredentials(name) || {}),
ELECTRON_RUN_AS_NODE: '1'
};
this.logToFile(name, `--- Starting ${name} in ${serviceDir} ---`);
const child = spawn(process.execPath, [scriptPath], {
cwd: serviceDir,
env,
stdio: ['ignore', 'pipe', 'pipe']
});
this.processes[name] = {
child,
relativeDir,
startScript,
status: 'starting'
};
if (this.stableTimers[name]) {
clearTimeout(this.stableTimers[name]);
}
this.stableTimers[name] = setTimeout(() => {
this.restartCounts[name] = 0;
}, 60000);
child.stdout.on('data', (data) => {
const text = data.toString().trim();
this.logToFile(name, `STDOUT: ${text}`);
});
child.stderr.on('data', (data) => {
const text = data.toString().trim();
this.logToFile(name, `STDERR: ${text}`);
});
child.on('exit', (code, signal) => {
this.logToFile(name, `EXITED: code=${code} signal=${signal}`);
this.processes[name] = null;
if (this.stableTimers[name]) {
clearTimeout(this.stableTimers[name]);
this.stableTimers[name] = null;
}
if (!this.isStopping) {
this.restartCounts[name] = (this.restartCounts[name] || 0) + 1;
if (this.restartCounts[name] > 5) {
this.logToFile(name, `Crashed too many times, giving up.`);
return;
}
setTimeout(() => {
this.startService(name, relativeDir, startScript).catch(err => {
this.logToFile(name, `Failed to restart: ${err.message}`);
});
}, 3000);
}
});
}
async startServices(services) {
await Promise.all(services.map(({ name, relativeDir, startScript }) =>
this.startService(name, relativeDir, startScript)
));
}
async waitForPorts(ports, timeoutMs = 30000) {
await Promise.all(ports.map((port) => this.waitForPort(port, timeoutMs)));
}
async stopAll() {
this.isStopping = true;
for (const name of Object.keys(this.stableTimers)) {
if (this.stableTimers[name]) {
clearTimeout(this.stableTimers[name]);
}
}
this.stableTimers = {};
const killPromises = Object.keys(this.processes).map((name) => {
const procInfo = this.processes[name];
if (procInfo && procInfo.child) {
this.logToFile(name, `Killing service`);
return new Promise((resolve) => {
procInfo.child.removeAllListeners('exit');
procInfo.child.on('exit', () => resolve());
procInfo.child.kill();
});
}
return Promise.resolve();
});
await Promise.all(killPromises);
this.processes = {};
}
async waitForPort(port, timeoutMs = 30000) {
const start = Date.now();
const net = require('net');
while (Date.now() - start < timeoutMs) {
if (this.isStopping) return false;
const connected = await new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(1000);
socket.on('connect', () => {
socket.destroy();
resolve(true);
});
socket.on('error', () => {
resolve(false);
});
socket.on('timeout', () => {
socket.destroy();
resolve(false);
});
socket.connect(port, '127.0.0.1');
});
if (connected) return true;
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`Timeout waiting for port ${port}`);
}
}
module.exports = ServiceManager;

BIN
source.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

222
update-client.js Normal file
View File

@@ -0,0 +1,222 @@
const https = require('https');
const fs = require('fs');
const GITLAB_GROUP = 'vyla-entertainment';
const GITLAB_REPO = 'home';
const RELEASES_API = `https://gitlab.com/api/v4/projects/${GITLAB_GROUP}%2F${GITLAB_REPO}/releases`;
const ALLOWED_REDIRECT_HOSTS = ['gitlab.com'];
function isAllowedHost(url) {
try {
return ALLOWED_REDIRECT_HOSTS.includes(new URL(url).hostname);
} catch (e) {
return false;
}
}
class UpdateClient {
constructor(appDataPath) {
this.appDataPath = appDataPath;
}
fetchJson(url) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const requestOptions = {
hostname: urlObj.hostname,
port: 443,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
'User-Agent': 'VylaHome-Client',
'Accept': 'application/json'
}
};
const req = https.request(requestOptions, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
return;
}
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error('Invalid JSON response'));
}
});
});
req.on('error', reject);
req.setTimeout(30000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
}
downloadFile(url, destPath, onProgress) {
return new Promise((resolve, reject) => {
const doRequest = (requestUrl, redirectCount) => {
if (redirectCount > 5) {
reject(new Error('Too many redirects'));
return;
}
const urlObj = new URL(requestUrl);
const requestOptions = {
hostname: urlObj.hostname,
port: 443,
path: urlObj.pathname + urlObj.search,
headers: {
'User-Agent': 'VylaHome-Client',
'Accept': 'application/octet-stream'
}
};
const req = https.get(requestOptions, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (!isAllowedHost(res.headers.location)) {
reject(new Error(`Redirect to disallowed host: ${res.headers.location}`));
return;
}
doRequest(res.headers.location, redirectCount + 1);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
return;
}
const totalSize = parseInt(res.headers['content-length'], 10);
let downloadedSize = 0;
const file = fs.createWriteStream(destPath);
res.pipe(file);
res.on('data', (chunk) => {
downloadedSize += chunk.length;
if (onProgress && totalSize) {
onProgress(downloadedSize, totalSize);
}
});
file.on('finish', () => {
file.close(resolve);
});
file.on('error', (err) => {
fs.unlink(destPath, () => { });
reject(err);
});
});
req.on('error', (err) => {
fs.unlink(destPath, () => { });
reject(err);
});
req.setTimeout(120000, () => {
req.destroy();
fs.unlink(destPath, () => { });
reject(new Error('Download timeout'));
});
};
doRequest(url, 0);
});
}
async fetchAssetJson(url) {
return new Promise((resolve, reject) => {
const doRequest = (requestUrl, redirectCount) => {
if (redirectCount > 5) {
reject(new Error('Too many redirects'));
return;
}
const urlObj = new URL(requestUrl);
const requestOptions = {
hostname: urlObj.hostname,
port: 443,
path: urlObj.pathname + urlObj.search,
headers: {
'User-Agent': 'VylaHome-Client',
'Accept': 'application/octet-stream'
}
};
const req = https.get(requestOptions, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
doRequest(res.headers.location, redirectCount + 1);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
return;
}
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error('Invalid JSON in manifest asset'));
}
});
});
req.on('error', reject);
req.setTimeout(30000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
};
doRequest(url, 0);
});
}
async checkForUpdates() {
const releases = await this.fetchJson(RELEASES_API);
if (!releases || releases.length === 0) {
throw new Error('No releases found.');
}
const release = releases[0];
const payloadAsset = (release.assets.links || []).find(a => a.name === 'update-payload.zip');
const manifestAsset = (release.assets.links || []).find(a => a.name === 'manifest.json');
if (!payloadAsset) {
throw new Error('No update-payload.zip asset found on latest release.');
}
let sha256 = null;
if (manifestAsset) {
const manifest = await this.fetchAssetJson(manifestAsset.url);
sha256 = manifest.sha256 || null;
}
return {
version: release.tag_name,
url: payloadAsset.url,
sha256
};
}
async downloadUpdate(release, destPath, onProgress) {
await this.downloadFile(release.url, destPath, onProgress);
return destPath;
}
}
module.exports = UpdateClient;