mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-09 19:57:20 -05:00
Sync Home
This commit is contained in:
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal 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
176
.gitlab-ci.yml
Normal 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
29
README.md
Normal 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
400
build-pipeline.js
Normal 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
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
6
build/logo.txt
Normal file
6
build/logo.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
██████╗ ██╗ ██╗██╗ ██╗██╗ █████╗ ███████╗███╗ ██╗████████╗███████╗██████╗ ████████╗ █████╗ ██╗███╗ ██╗███╗ ███╗███████╗███╗ ██╗████████╗
|
||||
██╔═══██╗██║ ██║╚██╗ ██╔╝██║ ██╔══██╗ ██╔════╝████╗ ██║╚══██╔══╝██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██║████╗ ██║████╗ ████║██╔════╝████╗ ██║╚══██╔══╝
|
||||
██║██╗██║██║ ██║ ╚████╔╝ ██║ ███████║ █████╗ ██╔██╗ ██║ ██║ █████╗ ██████╔╝ ██║ ███████║██║██╔██╗ ██║██╔████╔██║█████╗ ██╔██╗ ██║ ██║
|
||||
██║██║██║╚██╗ ██╔╝ ╚██╔╝ ██║ ██╔══██║ ██╔══╝ ██║╚██╗██║ ██║ ██╔══╝ ██╔══██╗ ██║ ██╔══██║██║██║╚██╗██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║ ██║
|
||||
╚█║████╔╝ ╚████╔╝ ██║ ███████╗██║ ██║ ███████╗██║ ╚████║ ██║ ███████╗██║ ██║ ██║ ██║ ██║██║██║ ╚████║██║ ╚═╝ ██║███████╗██║ ╚████║ ██║
|
||||
╚╝╚═══╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝
|
||||
16
config/credentials-template.json
Normal file
16
config/credentials-template.json
Normal 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
22
config/env.json
Normal 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
54
config/replacements.json
Normal 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
109
credential-manager.js
Normal 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
144
crypto-utils.js
Normal 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
179
github-client.js
Normal 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
7
last-shas.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"stream-api": "5eb845865e6633511b2f68a0e1615b2391ddca6e",
|
||||
"live-api-streampk": "6f2f3932360624b473c55becb7a5354b9a0fa804",
|
||||
"auth-proxy": "7660a5a252554af55bf84da219b63a76322d8b85",
|
||||
"player": "4b3a4a6523dbf019abe8f9ec02e689e5d4e3b672",
|
||||
"frontend": "ecab7ccd9fc2011bb78ac37f5b2a478ff25d162d"
|
||||
}
|
||||
664
main.js
Normal file
664
main.js
Normal 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
5281
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
package.json
Normal file
56
package.json
Normal 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
144
patcher.js
Normal 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
5
preload.js
Normal 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
170
service-manager.js
Normal 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
BIN
source.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
222
update-client.js
Normal file
222
update-client.js
Normal 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;
|
||||
Reference in New Issue
Block a user