7 Commits

Author SHA1 Message Date
oxmc
b32fcbd8f1 Fix auto update (win tested) and add stylus 2025-02-13 06:34:03 -08:00
oxmc
ab6fe350d3 Change linux ia32 build to armv7l 2024-12-28 12:19:56 -08:00
oxmc
d9c4a4a428 Improve app and add more install options (and fix old .dmg files) 2024-12-28 01:08:07 -08:00
oxmc
77dcea14cc Add userStyles support (wip) 2024-12-27 14:01:09 -08:00
oxmc
dcbbfde4a5 Reformat build dir 2024-12-20 09:28:49 -08:00
oxmc
685c2dc210 Fix sha256 method in aur build 2024-12-20 08:48:23 -08:00
oxmc
3d67f6d5ae change sha256 file paths 2024-12-20 08:36:27 -08:00
39 changed files with 3705 additions and 526 deletions

View File

@@ -1,65 +1,64 @@
name: Bug name: Bug
description: Make sure you complete the template. Otherwise, it will be closed without further explanation! description: Make sure you complete the template. Otherwise, it will be closed without further explanation!
title: "[v<replace_this_with_your_bsky-desktop_version>] Replace this with your title" title: "[v<replace_this_with_your_bsky-desktop_version>] Replace this with your title"
labels: bug labels: [bug]
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Is there an existing issue for this? label: Is there an existing issue for this?
description: _Please check the [**issues**](https://github.com/oxmc/bsky-desktop/issues) page to see if someone has already reported the bug. **I DIDN\'T MAKE THIS CHECKBOX FOR COSMETIC.**_ description: _Please check the [**issues**](https://github.com/oxmc/bsky-desktop/issues) page to see if someone has already reported the bug. **I DIDN\'T MAKE THIS CHECKBOX FOR COSMETIC.**_
options: options:
- label: I have searched the existing issues - label: I have searched the existing issues
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Device information label: Device information
description: description: "_Please provide the following information:_"
value: | value: |
- OS: - OS:
- Hardware Specs: - Hardware Specs:
- Etc: - Etc:
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Describe the issue label: Describe the issue
description: _Please attach videos or screenshots if possible_ description: _Please attach videos or screenshots if possible_
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Steps to reproduce label: Steps to reproduce
description: _Please attach videos or screenshots if possible_ description: _Please attach videos or screenshots if possible_
value: | value: |
1. 1.
2. 2.
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: logs id: logs
attributes: attributes:
label: Crash log label: Crash log
description: _If the app crashes, **please provide the crash log**. description: _If the app crashes, **please provide the crash log**.
render: shell render: shell
- type: dropdown - type: dropdown
attributes: attributes:
label: Are you using the latest version of bsky-destop? If not, why? label: Are you using the latest version of bsky-destop? If not, why?
description: _Developers spent loads of time and effort to fix bugs & make improvements with every release. You might want to try and update to the [latest version](https://github.com/oxmc/bsky-desktop/releases) before reporting an issue._ description: _Developers spent loads of time and effort to fix bugs & make improvements with every release. You might want to try and update to the [latest version](https://github.com/oxmc/bsky-desktop/releases) before reporting an issue._
multiple: false multiple: false
options: options:
- ✅ Yes, I'm using the latest version of bsky-desktop - ✅ Yes, I'm using the latest version of bsky-desktop
- ❌ No, I'll explain with additional information below - ❌ No, I'll explain with additional information below
validations: validations:
required: true required: true
- type: textarea
attributes:
label: Additional information
validations:
required: false
- type: textarea
attributes:
label: Additional information
validations:
required: false

View File

@@ -31,6 +31,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Build (armv7l)
run: npm run build -- --arch armv7l
- name: Build (x64) - name: Build (x64)
run: npm run build -- --arch x64 run: npm run build -- --arch x64
@@ -39,7 +42,7 @@ jobs:
- name: Download appimagelint and its deps - name: Download appimagelint and its deps
run: | run: |
sudo apt install fuse -y sudo apt update && sudo apt install fuse -y
wget https://github.com/TheAssassin/appimagelint/releases/download/continuous/appimagelint-x86_64.AppImage wget https://github.com/TheAssassin/appimagelint/releases/download/continuous/appimagelint-x86_64.AppImage
chmod +x appimagelint-x86_64.AppImage chmod +x appimagelint-x86_64.AppImage
@@ -48,7 +51,9 @@ jobs:
- name: Generate checksum - name: Generate checksum
run: | run: |
sha256sum dist/*.AppImage > sha256sum.txt sha256sum dist/*.AppImage > dist/sha256sum.txt
sha256sum dist/*.deb >> dist/sha256sum.txt
sha256sum dist/*.zip >> dist/sha256sum.txt
- name: Upload Linux Artifacts - name: Upload Linux Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -57,14 +62,15 @@ jobs:
name: linux-artifacts name: linux-artifacts
path: | path: |
dist/*.AppImage dist/*.AppImage
dist/*.deb
dist/*.zip
dist/latest*.yml dist/latest*.yml
sha256sum.txt dist/sha256sum.txt
build-windows: build-windows:
name: Build bsky-desktop (Windows) name: Build bsky-desktop (Windows)
runs-on: windows-latest runs-on: windows-latest
env: env:
ext: "exe"
GITHUB_TOKEN: ${{ secrets.GHT }} GITHUB_TOKEN: ${{ secrets.GHT }}
steps: steps:
@@ -78,16 +84,22 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Build (ia32)
run: npm run build -- --arch ia32
- name: Build (x64) - name: Build (x64)
run: npm run build -- --arch x64 run: npm run build -- --arch x64
- name: Build (arm64) - name: Build (arm64)
run: npm run build -- --arch arm64 run: npm run build -- --arch arm64
- name: Generate checksum - name: Generate checksum
run: | run: |
sha256sum dist/*.exe > sha256sum.txt sha256sum dist/*.exe > dist/sha256sum.txt
sha256sum dist/*.msi >> dist/sha256sum.txt
sha256sum dist/*.appx >> dist/sha256sum.txt
sha256sum dist/*.zip >> dist/sha256sum.txt
- name: Upload Windows Artifacts - name: Upload Windows Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -96,14 +108,16 @@ jobs:
name: windows-artifacts name: windows-artifacts
path: | path: |
dist/*.exe dist/*.exe
dist/*.msi
dist/*.appx
dist/*.zip
dist/latest*.yml dist/latest*.yml
sha256sum.txt dist/sha256sum.txt
build-macos: build-macos:
name: Build bsky-desktop (macOS) name: Build bsky-desktop (macOS)
runs-on: macos-latest runs-on: macos-latest
env: env:
ext: "dmg"
GITHUB_TOKEN: ${{ secrets.GHT }} GITHUB_TOKEN: ${{ secrets.GHT }}
steps: steps:
@@ -126,8 +140,9 @@ jobs:
- name: Generate checksum - name: Generate checksum
run: | run: |
shasum -a 256 dist/*.dmg > sha256sum.txt shasum -a 256 dist/*.dmg > dist/sha256sum.txt
# shasum -a 256 dist/*.pkg >> sha256sum.txt shasum -a 256 dist/*.pkg >> dist/sha256sum.txt
shasum -a 256 dist/*.zip >> dist/sha256sum.txt
- name: Upload macOS Artifacts - name: Upload macOS Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -136,8 +151,10 @@ jobs:
name: macos-artifacts name: macos-artifacts
path: | path: |
dist/*.dmg dist/*.dmg
dist/*.pkg
dist/*.zip
dist/latest*.yml dist/latest*.yml
sha256sum.txt dist/sha256sum.txt
release: release:
name: Create Release name: Create Release
@@ -190,8 +207,15 @@ jobs:
generate_release_notes: true generate_release_notes: true
files: | files: |
dist/linux/*.AppImage dist/linux/*.AppImage
dist/linux/*.deb
dist/linux/*.zip
dist/windows/*.exe dist/windows/*.exe
dist/windows/*.msi
dist/windows/*.appx
dist/windows/*.zip
dist/macos/*.dmg dist/macos/*.dmg
dist/macos/*.pkg
dist/macos/*.zip
sha256sums.txt sha256sums.txt
aur: aur:
@@ -224,14 +248,14 @@ jobs:
- name: Extract checksum from sha256sum.txt and change build version - name: Extract checksum from sha256sum.txt and change build version
run: | run: |
new_checksum=$(awk 'NR==1 { print $1 }' ./dist/linux/sha256sum.txt) new_checksum=$(awk 'NR==1 { print $1 }' ./dist/linux/sha256sum.txt)
sed -i "s|sha256sums=('SKIP' 'SKIP')|sha256sums=('$new_checksum' 'SKIP')|" ./build/PKGBUILD sed -i "s|sha256sums=('SKIP' 'SKIP')|sha256sums=('$new_checksum' 'SKIP')|" ./build/arch-pkg/PKGBUILD
sed -i "s/^pkgver=.*$/pkgver=${{ steps.version.outputs.version }}/" ./build/PKGBUILD sed -i "s/^pkgver=.*$/pkgver=${{ steps.version.outputs.version }}/" ./build/arch-pkg/PKGBUILD
- name: Publish AUR package - name: Publish AUR package
uses: KSXGitHub/github-actions-deploy-aur@v3.0.1 uses: KSXGitHub/github-actions-deploy-aur@v3.0.1
with: with:
pkgname: bskydesktop pkgname: bskydesktop
pkgbuild: ./build/PKGBUILD pkgbuild: ./build/arch-pkg/PKGBUILD
commit_username: ${{ secrets.AUR_USERNAME }} commit_username: ${{ secrets.AUR_USERNAME }}
commit_email: ${{ secrets.AUR_EMAIL }} commit_email: ${{ secrets.AUR_EMAIL }}
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}

View File

@@ -1,3 +1,58 @@
# Bsky Desktop # Bsky Desktop
Bsky Desktop is an application for Bsky built using Electron. It allows users to manage their Bsky account and feeds from the app instead of the web. Bsky Desktop is an Electron-based application for Bsky that allows users to manage their accounts and feeds directly from the app, rather than through the web interface.
### Features:
- Support for user styles (work in progress; currently only LESS preprocessor is supported)
- Compatibility with both Manifest V2 and V3 Chrome extensions, though only a limited set of Chrome extension APIs are supported. For more information, visit: [Electron Extensions API Documentation](https://www.electronjs.org/docs/latest/api/extensions#supported-extensions-apis)
### Working on:
- Auto updates (for all platforms)
### Build and release status:
[![Build and Release bsky-desktop](https://github.com/oxmc/bsky-desktop/actions/workflows/build-and-release.yml/badge.svg)](https://github.com/oxmc/bsky-desktop/actions/workflows/build-and-release.yml)
[![Packaging status](https://repology.org/badge/vertical-allrepos/bskydesktop.svg?columns=4&exclude_unsupported=1)](https://repology.org/project/bskydesktop/versions)
#### Windows install options:
- Zip (x64, arm64, ia32)
- Setup (exe, msi, appx) (x64, arm64, ia32)
#### Mac install options:
- Zip (x64, arm64)
- Dmg (x64, arm64)
- Pkg (x64, arm64)
#### Linux install options:
- Zip (x64, arm64, ia32)
- AppImage (x64, arm64, ia32)
- Deb (x64, arm64, ia32)
### Build Instructions for Bsky Desktop
To build and run Bsky Desktop locally, follow these steps:
1. **Clone the repository:**
```sh
git clone https://github.com/oxmc/bsky-desktop.git
cd bsky-desktop
```
2. **Install dependencies:**
```sh
npm install
```
**(Optional) Run the application locally:**
If you want to test the application locally before building it, use the following command:
```sh
npm run start
```
This step is **not required for building** but is useful if you want to see the app in action during development.
3. **Build the application:**
To compile the application, run:
```sh
npm run build
```
This will generate the necessary files for the app.

View File

@@ -1,116 +0,0 @@
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs/promises');
const process = require('process');
const packageJson = require('./package.json');
// Electron builder:
const electronBuilderPath = path.join('node_modules', 'electron-builder', 'cli.js');
// Parse command-line arguments:
const supportedPlatforms = ['win', 'mac', 'linux', 'mwl'];
const supportedArchitectures = ['x64', 'armv7l', 'arm64', 'ia32', 'universal'];
const args = process.argv.slice(2);
const carch = args.includes('--arch') ? args[args.indexOf('--arch') + 1] : null;
const cplatform = args.includes('--platform') ? args[args.indexOf('--platform') + 1] : null;
const pack = args.includes('--pack') || null;
let platform, arch, build_args;
//console.log(supportedPlatforms, supportedArchitectures, cplatform, carch);
// Platform Name:
if (cplatform == null) {
switch (process.platform) {
case "win32":
platform = "win";
break;
case "darwin":
platform = "mac";
break;
case "linux":
platform = "linux";
break;
default:
platform = "mwl"; // Build for all
break;
}
} else {
if (!supportedPlatforms.includes(cplatform)) {
console.error(`Invalid platform specified. Supported platforms: ${supportedPlatforms.join(', ')}`);
process.exit(1);
}
platform = cplatform;
}
// Platform Arch:
if (carch == null) {
arch = process.arch === "arm" ? "armv7l" : process.arch;
} else {
arch = carch;
if (!supportedArchitectures.includes(arch)) {
console.error(`Invalid arch specified. Supported architectures: ${supportedArchitectures.join(', ')}`);
process.exit(1);
}
}
// Generate artifact name:
const artifactname = `${packageJson.build.productName}-${packageJson.version}-${platform}-${arch}`;
(async () => {
try {
// Additional build arguments: (started wiht --eb-nmehere)
const additionalArgs = args.filter((arg, index) => arg.startsWith('--eb-') && index % 2 !== 0);
// CLI Args:
build_args = [
electronBuilderPath,
`--${platform}`,
`--${arch}`,
`-c.artifactName="${artifactname}.\${ext}"`,
];
// If additional args are present:
if (additionalArgs.length > 0) {
build_args.push(...additionalArgs);
}
// If pack is true:
if (pack) {
build_args.push(`--dir`);
}
// Make CLI:
const cli = `node ${build_args.join(' ')}`;
console.info(`CLI: ${cli}`);
console.info(`Building ${artifactname}`);
const process = spawn(cli, { shell: true });
// Log stdout as it comes
process.stdout.on('data', (data) => {
console.log(data.toString().trim());
});
// Log stderr as it comes
process.stderr.on('data', (data) => {
console.error(data.toString().trim());
});
// Handle process exit
process.on('close', (code) => {
if (code === 0) {
console.info(`Build completed successfully!`);
} else {
console.error(`Process exited with code ${code}`);
}
});
process.on('error', (error) => {
console.error(`Failed to start process: ${error.message}`);
});
} catch (error) {
console.error('An error occurred:', error);
process.exit(1);
}
})();

View File

@@ -16,14 +16,17 @@ icon_name="bsky-desktop.png"
prepare() { prepare() {
latest_tag=$(curl -s "https://api.github.com/repos/oxmc/bsky-desktop/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")') latest_tag=$(curl -s "https://api.github.com/repos/oxmc/bsky-desktop/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")')
latest_sha256=$(curl -Ls "https://github.com/oxmc/bsky-desktop/releases/download/$latest_tag/sha256sums.txt" | grep "AppImage")
echo "Latest release tag: $latest_tag" echo "Latest release tag: $latest_tag"
case "$CARCH" in case "$CARCH" in
x86_64) x86_64)
appimage_name="bskyDesktop-${latest_tag:1}-linux-x64.AppImage" appimage_name="bskyDesktop-${latest_tag:1}-linux-x64.AppImage"
sha256sum=$(echo "$latest_sha256" | grep "x64" | cut -d' ' -f1)
;; ;;
aarch64) aarch64)
appimage_name="bskyDesktop-${latest_tag:1}-linux-arm64.AppImage" appimage_name="bskyDesktop-${latest_tag:1}-linux-arm64.AppImage"
sha256sum=$(echo "$latest_sha256" | grep "arm64" | cut -d' ' -f1)
;; ;;
*) *)
echo "Unsupported architecture: $CARCH" echo "Unsupported architecture: $CARCH"
@@ -36,7 +39,7 @@ prepare() {
"$icon_url" "$icon_url"
) )
echo "AppImage source: ${source[0]}" echo "AppImage source: ${source[0]}"
sha256sums=('SKIP' 'SKIP') sha256sums=("$sha256sum" 'SKIP')
curl -L "${source[0]}" -o "$srcdir/bskyDesktop.appimage" curl -L "${source[0]}" -o "$srcdir/bskyDesktop.appimage"
curl -L "${source[1]}" -o "$srcdir/$icon_name" curl -L "${source[1]}" -o "$srcdir/$icon_name"
} }

129
build/build-app.js Normal file
View File

@@ -0,0 +1,129 @@
const path = require('path');
const process = require('process');
const builder = require('electron-builder');
const { Platform, Arch } = builder;
// Parse command-line arguments
const supportedPlatforms = ['win', 'mac', 'linux'];
const supportedArchitectures = ['x64', 'armv7l', 'arm64', 'ia32', 'universal'];
const args = process.argv.slice(2);
const carch = args.includes('--arch') ? args[args.indexOf('--arch') + 1] : null;
const cplatform = args.includes('--platform') ? args[args.indexOf('--platform') + 1] : null;
const pack = args.includes('--pack'); // Keep track of --pack as a boolean flag
const nobc = args.includes('--no-bc'); // Keep track of --nobc as a boolean flag
// Determine platform
let platform;
if (!cplatform) {
platform = process.platform === 'win32' ? 'win' :
process.platform === 'darwin' ? 'mac' :
process.platform === 'linux' ? 'linux' : null;
} else if (!supportedPlatforms.includes(cplatform)) {
console.error(`Invalid platform specified. Supported platforms: ${supportedPlatforms.join(', ')}`);
process.exit(1);
} else {
platform = cplatform;
}
// Determine architecture
let arch = carch || (process.arch === 'arm' ? 'armv7l' : process.arch);
if (!supportedArchitectures.includes(arch)) {
console.error(`Invalid architecture specified. Supported architectures: ${supportedArchitectures.join(', ')}`);
process.exit(1);
}
// Map platform and architecture to electron-builder enums
const platformEnum = {
win: Platform.WINDOWS,
mac: Platform.MAC,
linux: Platform.LINUX
}[platform];
const archEnum = {
x64: Arch.x64,
armv7l: Arch.armv7l,
arm64: Arch.arm64,
ia32: Arch.ia32,
universal: Arch.universal
}[arch];
// Additional build arguments: (starting with --eb-)
const buildArgs = args.filter((arg) => arg.startsWith('--eb-'));
// If additional args are present, add them to the buildArgs array
if (buildArgs.length > 0) {
console.log('Additional build arguments:', buildArgs);
}
// If pack is true, add --dir to buildArgs
if (pack) {
buildArgs.push('--dir');
}
// Default build configuration (used when --no-bc is passed) [Default values are for bskyDesktop]
const defaultBuildConfig = {
"appId": "com.oxmc.bskyDesktop",
"productName": "bskyDesktop",
"asarUnpack": [
"./node_modules/node-notifier/**/*"
],
"win": {
"icon": "src/ui/images/logo.ico",
},
"mac": {
"icon": "src/ui/images/mac.icns",
},
"linux": {
"icon": "src/ui/images/icons",
},
};
// Read build-config.json if it exists and --no-bc is not present
const buildConfigPath = path.join(__dirname, 'build-config.json');
let buildConfig = defaultBuildConfig;
if (!nobc) {
if (!require('fs').existsSync(buildConfigPath)) {
console.error('build-config.json not found');
process.exit(1);
}
try {
buildConfig = require(buildConfigPath);
} catch (err) {
console.error(`Failed to read build-config.json: ${err.message}`);
process.exit(1);
}
}
// Generate artifact name
const packageJson = require('../package.json');
const artifactName = `${buildConfig.productName}-${packageJson.version}-${platform}-${arch}.\${ext}`;
// Electron Builder configuration
/**
* @type {import('electron-builder').Configuration}
*/
const options = {
artifactName,
nsis: {
deleteAppDataOnUninstall: true,
oneClick: true,
perMachine: false
},
/*dmg: {
contents: [
{ type: 'file', x: 255, y: 85 },
{ type: 'link', path: '/Applications', x: 253, y: 325 }
]
},*/
...buildConfig
};
// Build process
builder.build({
targets: platformEnum.createTarget(null, archEnum),
config: options
}).then(result => {
console.log("Build completed:", JSON.stringify(result, null, 2));
}).catch(error => {
console.error("Build failed:", error);
});

55
build/build-config.json Normal file
View File

@@ -0,0 +1,55 @@
{
"appId": "com.oxmc.bskyDesktop",
"productName": "bskyDesktop",
"asarUnpack": [
"./node_modules/node-notifier/**/*"
],
"removePackageScripts": true,
"mac": {
"target": [
"dmg",
"pkg",
"zip"
],
"icon": "src/ui/images/mac.icns",
"category": "Network"
},
"pkg": {
"installLocation": "/Applications",
"allowAnywhere": true,
"allowCurrentUserHome": true,
"allowRootDirectory": true,
"isVersionChecked": true,
"isRelocatable": false,
"overwriteAction": "upgrade"
},
"linux": {
"target": [
"appimage",
"deb",
"zip"
],
"icon": "src/ui/images/icons",
"category": "Network"
},
"win": {
"target": [
"nsis",
"appx",
"msi",
"zip"
],
"icon": "src/ui/images/logo.ico"
},
"appx": {
"identityName": "BskyDesktop"
},
"protocols": [
{
"name": "bsky",
"schemes": [
"bsky"
]
}
]
}

600
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "bsky-desktop", "name": "bsky-desktop",
"version": "1.0.9", "version": "1.1.3",
"description": "A desktop app of bsky.app", "description": "A desktop app of bsky.app",
"main": "src/app/main.js", "main": "src/app/main.js",
"scripts": { "scripts": {
@@ -8,7 +8,7 @@
"pack": "electron-builder --dir", "pack": "electron-builder --dir",
"dist": "electron-builder", "dist": "electron-builder",
"rebuild": "electron-rebuild", "rebuild": "electron-rebuild",
"build": "node ./build-app.js" "build": "node ./build/build-app.js"
}, },
"author": { "author": {
"name": "oxmc", "name": "oxmc",
@@ -24,47 +24,13 @@
"@electron/remote": "^2.1.2", "@electron/remote": "^2.1.2",
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"axios": "^1.6.8", "axios": "^1.6.8",
"electron-window-state": "^5.0.3", "less": "^4.2.1",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"node-notifier": "^10.0.0", "node-notifier": "^10.0.0",
"semver": "^7.6.3", "semver": "^7.6.3",
"v8-compile-cache": "^2.3.0" "stylus": "^0.64.0",
}, "usercss-meta": "^0.12.0",
"build": { "v8-compile-cache": "^2.3.0",
"appId": "com.oxmc.bskyDesktop", "winreg": "^1.2.5"
"productName": "bskyDesktop",
"asarUnpack": [
"./node_modules/node-notifier/**/*"
],
"artifactName": "bsky-desktop.${ext}",
"mac": {
"target": [
"dmg",
"pkg"
],
"icon": "src/ui/images/logo.icns",
"category": "Network"
},
"linux": {
"target": [
"appimage"
],
"icon": "src/ui/images/icons",
"category": "Network"
},
"win": {
"target": [
"nsis"
],
"icon": "src/ui/images/logo.ico"
},
"protocols": [
{
"name": "bsky",
"schemes": [
"bsky"
]
}
]
} }
} }

17
src/app/contributors.json Normal file
View File

@@ -0,0 +1,17 @@
[
{
"name": "oxmc",
"email": "contact@oxmc.is-a.dev",
"url": "https://oxmc.is-a.dev"
},
{
"name": "PlOszukiwaczDEV",
"email": "ploszukiwacz1@duck.com",
"url": "https://ploszukiwacz.pl"
},
{
"name": "GizzyUwU",
"email": "me@gizzy.pro",
"url": "https://gizzy.pro"
}
]

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 devsnek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,660 @@
'use strict';
const EventEmitter = require('events');
const { setTimeout, clearTimeout } = require('timers');
const fetch = require('node-fetch');
const transports = require('./transports');
const { RPCCommands, RPCEvents, RelationshipTypes } = require('./constants');
const { pid: getPid, uuid } = require('./util');
function subKey(event, args) {
return `${event}${JSON.stringify(args)}`;
}
/**
* @typedef {RPCClientOptions}
* @extends {ClientOptions}
* @prop {string} transport RPC transport. one of `ipc` or `websocket`
*/
/**
* The main hub for interacting with Discord RPC
* @extends {BaseClient}
*/
class RPCClient extends EventEmitter {
/**
* @param {RPCClientOptions} [options] Options for the client.
* You must provide a transport
*/
constructor(options = {}) {
super();
this.options = options;
this.accessToken = null;
this.clientId = null;
/**
* Application used in this client
* @type {?ClientApplication}
*/
this.application = null;
/**
* User used in this application
* @type {?User}
*/
this.user = null;
const Transport = transports[options.transport];
if (!Transport) {
throw new TypeError('RPC_INVALID_TRANSPORT', options.transport);
}
this.fetch = (method, path, { data, query } = {}) =>
fetch(`${this.fetch.endpoint}${path}${query ? new URLSearchParams(query) : ''}`, {
method,
body: data,
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
}).then(async (r) => {
const body = await r.json();
if (!r.ok) {
const e = new Error(r.status);
e.body = body;
throw e;
}
return body;
});
this.fetch.endpoint = 'https://discord.com/api';
/**
* Raw transport userd
* @type {RPCTransport}
* @private
*/
this.transport = new Transport(this);
this.transport.on('message', this._onRpcMessage.bind(this));
/**
* Map of nonces being expected from the transport
* @type {Map}
* @private
*/
this._expecting = new Map();
this._connectPromise = undefined;
}
/**
* Search and connect to RPC
*/
connect(clientId) {
if (this._connectPromise) {
return this._connectPromise;
}
this._connectPromise = new Promise((resolve, reject) => {
this.clientId = clientId;
const timeout = setTimeout(() => reject(new Error('RPC_CONNECTION_TIMEOUT')), 10e3);
timeout.unref();
this.once('connected', () => {
clearTimeout(timeout);
resolve(this);
});
this.transport.once('close', () => {
this._expecting.forEach((e) => {
e.reject(new Error('connection closed'));
});
this.emit('disconnected');
reject(new Error('connection closed'));
});
this.transport.connect().catch(reject);
});
return this._connectPromise;
}
/**
* @typedef {RPCLoginOptions}
* @param {string} clientId Client ID
* @param {string} [clientSecret] Client secret
* @param {string} [accessToken] Access token
* @param {string} [rpcToken] RPC token
* @param {string} [tokenEndpoint] Token endpoint
* @param {string[]} [scopes] Scopes to authorize with
*/
/**
* Performs authentication flow. Automatically calls Client#connect if needed.
* @param {RPCLoginOptions} options Options for authentication.
* At least one property must be provided to perform login.
* @example client.login({ clientId: '1234567', clientSecret: 'abcdef123' });
* @returns {Promise<RPCClient>}
*/
async login(options = {}) {
let { clientId, accessToken } = options;
await this.connect(clientId);
if (!options.scopes) {
this.emit('ready');
return this;
}
if (!accessToken) {
accessToken = await this.authorize(options);
}
return this.authenticate(accessToken);
}
/**
* Request
* @param {string} cmd Command
* @param {Object} [args={}] Arguments
* @param {string} [evt] Event
* @returns {Promise}
* @private
*/
request(cmd, args, evt) {
return new Promise((resolve, reject) => {
const nonce = uuid();
this.transport.send({ cmd, args, evt, nonce });
this._expecting.set(nonce, { resolve, reject });
});
}
/**
* Message handler
* @param {Object} message message
* @private
*/
_onRpcMessage(message) {
if (message.cmd === RPCCommands.DISPATCH && message.evt === RPCEvents.READY) {
if (message.data.user) {
this.user = message.data.user;
}
this.emit('connected');
} else if (this._expecting.has(message.nonce)) {
const { resolve, reject } = this._expecting.get(message.nonce);
if (message.evt === 'ERROR') {
const e = new Error(message.data.message);
e.code = message.data.code;
e.data = message.data;
reject(e);
} else {
resolve(message.data);
}
this._expecting.delete(message.nonce);
} else {
this.emit(message.evt, message.data);
}
}
/**
* Authorize
* @param {Object} options options
* @returns {Promise}
* @private
*/
async authorize({ scopes, clientSecret, rpcToken, redirectUri, prompt } = {}) {
if (clientSecret && rpcToken === true) {
const body = await this.fetch('POST', '/oauth2/token/rpc', {
data: new URLSearchParams({
client_id: this.clientId,
client_secret: clientSecret,
}),
});
rpcToken = body.rpc_token;
}
const { code } = await this.request('AUTHORIZE', {
scopes,
client_id: this.clientId,
prompt,
rpc_token: rpcToken,
});
const response = await this.fetch('POST', '/oauth2/token', {
data: new URLSearchParams({
client_id: this.clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
}),
});
return response.access_token;
}
/**
* Authenticate
* @param {string} accessToken access token
* @returns {Promise}
* @private
*/
authenticate(accessToken) {
return this.request('AUTHENTICATE', { access_token: accessToken })
.then(({ application, user }) => {
this.accessToken = accessToken;
this.application = application;
this.user = user;
this.emit('ready');
return this;
});
}
/**
* Fetch a guild
* @param {Snowflake} id Guild ID
* @param {number} [timeout] Timeout request
* @returns {Promise<Guild>}
*/
getGuild(id, timeout) {
return this.request(RPCCommands.GET_GUILD, { guild_id: id, timeout });
}
/**
* Fetch all guilds
* @param {number} [timeout] Timeout request
* @returns {Promise<Collection<Snowflake, Guild>>}
*/
getGuilds(timeout) {
return this.request(RPCCommands.GET_GUILDS, { timeout });
}
/**
* Get a channel
* @param {Snowflake} id Channel ID
* @param {number} [timeout] Timeout request
* @returns {Promise<Channel>}
*/
getChannel(id, timeout) {
return this.request(RPCCommands.GET_CHANNEL, { channel_id: id, timeout });
}
/**
* Get all channels
* @param {Snowflake} [id] Guild ID
* @param {number} [timeout] Timeout request
* @returns {Promise<Collection<Snowflake, Channel>>}
*/
async getChannels(id, timeout) {
const { channels } = await this.request(RPCCommands.GET_CHANNELS, {
timeout,
guild_id: id,
});
return channels;
}
/**
* @typedef {CertifiedDevice}
* @prop {string} type One of `AUDIO_INPUT`, `AUDIO_OUTPUT`, `VIDEO_INPUT`
* @prop {string} uuid This device's Windows UUID
* @prop {object} vendor Vendor information
* @prop {string} vendor.name Vendor's name
* @prop {string} vendor.url Vendor's url
* @prop {object} model Model information
* @prop {string} model.name Model's name
* @prop {string} model.url Model's url
* @prop {string[]} related Array of related product's Windows UUIDs
* @prop {boolean} echoCancellation If the device has echo cancellation
* @prop {boolean} noiseSuppression If the device has noise suppression
* @prop {boolean} automaticGainControl If the device has automatic gain control
* @prop {boolean} hardwareMute If the device has a hardware mute
*/
/**
* Tell discord which devices are certified
* @param {CertifiedDevice[]} devices Certified devices to send to discord
* @returns {Promise}
*/
setCertifiedDevices(devices) {
return this.request(RPCCommands.SET_CERTIFIED_DEVICES, {
devices: devices.map((d) => ({
type: d.type,
id: d.uuid,
vendor: d.vendor,
model: d.model,
related: d.related,
echo_cancellation: d.echoCancellation,
noise_suppression: d.noiseSuppression,
automatic_gain_control: d.automaticGainControl,
hardware_mute: d.hardwareMute,
})),
});
}
/**
* @typedef {UserVoiceSettings}
* @prop {Snowflake} id ID of the user these settings apply to
* @prop {?Object} [pan] Pan settings, an object with `left` and `right` set between
* 0.0 and 1.0, inclusive
* @prop {?number} [volume=100] The volume
* @prop {bool} [mute] If the user is muted
*/
/**
* Set the voice settings for a user, by id
* @param {Snowflake} id ID of the user to set
* @param {UserVoiceSettings} settings Settings
* @returns {Promise}
*/
setUserVoiceSettings(id, settings) {
return this.request(RPCCommands.SET_USER_VOICE_SETTINGS, {
user_id: id,
pan: settings.pan,
mute: settings.mute,
volume: settings.volume,
});
}
/**
* Move the user to a voice channel
* @param {Snowflake} id ID of the voice channel
* @param {Object} [options] Options
* @param {number} [options.timeout] Timeout for the command
* @param {boolean} [options.force] Force this move. This should only be done if you
* have explicit permission from the user.
* @returns {Promise}
*/
selectVoiceChannel(id, { timeout, force = false } = {}) {
return this.request(RPCCommands.SELECT_VOICE_CHANNEL, { channel_id: id, timeout, force });
}
/**
* Move the user to a text channel
* @param {Snowflake} id ID of the voice channel
* @param {Object} [options] Options
* @param {number} [options.timeout] Timeout for the command
* have explicit permission from the user.
* @returns {Promise}
*/
selectTextChannel(id, { timeout } = {}) {
return this.request(RPCCommands.SELECT_TEXT_CHANNEL, { channel_id: id, timeout });
}
/**
* Get current voice settings
* @returns {Promise}
*/
getVoiceSettings() {
return this.request(RPCCommands.GET_VOICE_SETTINGS)
.then((s) => ({
automaticGainControl: s.automatic_gain_control,
echoCancellation: s.echo_cancellation,
noiseSuppression: s.noise_suppression,
qos: s.qos,
silenceWarning: s.silence_warning,
deaf: s.deaf,
mute: s.mute,
input: {
availableDevices: s.input.available_devices,
device: s.input.device_id,
volume: s.input.volume,
},
output: {
availableDevices: s.output.available_devices,
device: s.output.device_id,
volume: s.output.volume,
},
mode: {
type: s.mode.type,
autoThreshold: s.mode.auto_threshold,
threshold: s.mode.threshold,
shortcut: s.mode.shortcut,
delay: s.mode.delay,
},
}));
}
/**
* Set current voice settings, overriding the current settings until this session disconnects.
* This also locks the settings for any other rpc sessions which may be connected.
* @param {Object} args Settings
* @returns {Promise}
*/
setVoiceSettings(args) {
return this.request(RPCCommands.SET_VOICE_SETTINGS, {
automatic_gain_control: args.automaticGainControl,
echo_cancellation: args.echoCancellation,
noise_suppression: args.noiseSuppression,
qos: args.qos,
silence_warning: args.silenceWarning,
deaf: args.deaf,
mute: args.mute,
input: args.input ? {
device_id: args.input.device,
volume: args.input.volume,
} : undefined,
output: args.output ? {
device_id: args.output.device,
volume: args.output.volume,
} : undefined,
mode: args.mode ? {
type: args.mode.type,
auto_threshold: args.mode.autoThreshold,
threshold: args.mode.threshold,
shortcut: args.mode.shortcut,
delay: args.mode.delay,
} : undefined,
});
}
/**
* Capture a shortcut using the client
* The callback takes (key, stop) where `stop` is a function that will stop capturing.
* This `stop` function must be called before disconnecting or else the user will have
* to restart their client.
* @param {Function} callback Callback handling keys
* @returns {Promise<Function>}
*/
captureShortcut(callback) {
const subid = subKey(RPCEvents.CAPTURE_SHORTCUT_CHANGE);
const stop = () => {
this._subscriptions.delete(subid);
return this.request(RPCCommands.CAPTURE_SHORTCUT, { action: 'STOP' });
};
this._subscriptions.set(subid, ({ shortcut }) => {
callback(shortcut, stop);
});
return this.request(RPCCommands.CAPTURE_SHORTCUT, { action: 'START' })
.then(() => stop);
}
/**
* Sets the presence for the logged in user.
* @param {object} args The rich presence to pass.
* @param {number} [pid] The application's process ID. Defaults to the executing process' PID.
* @returns {Promise}
*/
setActivity(args = {}, pid = getPid()) {
let timestamps;
let assets;
let party;
let secrets;
if (args.startTimestamp || args.endTimestamp) {
timestamps = {
start: args.startTimestamp,
end: args.endTimestamp,
};
if (timestamps.start instanceof Date) {
timestamps.start = Math.round(timestamps.start.getTime());
}
if (timestamps.end instanceof Date) {
timestamps.end = Math.round(timestamps.end.getTime());
}
if (timestamps.start > 2147483647000) {
throw new RangeError('timestamps.start must fit into a unix timestamp');
}
if (timestamps.end > 2147483647000) {
throw new RangeError('timestamps.end must fit into a unix timestamp');
}
}
if (
args.largeImageKey || args.largeImageText
|| args.smallImageKey || args.smallImageText
) {
assets = {
large_image: args.largeImageKey,
large_text: args.largeImageText,
small_image: args.smallImageKey,
small_text: args.smallImageText,
};
}
if (args.partySize || args.partyId || args.partyMax) {
party = { id: args.partyId };
if (args.partySize || args.partyMax) {
party.size = [args.partySize, args.partyMax];
}
}
if (args.matchSecret || args.joinSecret || args.spectateSecret) {
secrets = {
match: args.matchSecret,
join: args.joinSecret,
spectate: args.spectateSecret,
};
}
return this.request(RPCCommands.SET_ACTIVITY, {
pid,
activity: {
state: args.state,
details: args.details,
timestamps,
assets,
party,
secrets,
buttons: args.buttons,
instance: !!args.instance,
},
});
}
/**
* Clears the currently set presence, if any. This will hide the "Playing X" message
* displayed below the user's name.
* @param {number} [pid] The application's process ID. Defaults to the executing process' PID.
* @returns {Promise}
*/
clearActivity(pid = getPid()) {
return this.request(RPCCommands.SET_ACTIVITY, {
pid,
});
}
/**
* Invite a user to join the game the RPC user is currently playing
* @param {User} user The user to invite
* @returns {Promise}
*/
sendJoinInvite(user) {
return this.request(RPCCommands.SEND_ACTIVITY_JOIN_INVITE, {
user_id: user.id || user,
});
}
/**
* Request to join the game the user is playing
* @param {User} user The user whose game you want to request to join
* @returns {Promise}
*/
sendJoinRequest(user) {
return this.request(RPCCommands.SEND_ACTIVITY_JOIN_REQUEST, {
user_id: user.id || user,
});
}
/**
* Reject a join request from a user
* @param {User} user The user whose request you wish to reject
* @returns {Promise}
*/
closeJoinRequest(user) {
return this.request(RPCCommands.CLOSE_ACTIVITY_JOIN_REQUEST, {
user_id: user.id || user,
});
}
createLobby(type, capacity, metadata) {
return this.request(RPCCommands.CREATE_LOBBY, {
type,
capacity,
metadata,
});
}
updateLobby(lobby, { type, owner, capacity, metadata } = {}) {
return this.request(RPCCommands.UPDATE_LOBBY, {
id: lobby.id || lobby,
type,
owner_id: (owner && owner.id) || owner,
capacity,
metadata,
});
}
deleteLobby(lobby) {
return this.request(RPCCommands.DELETE_LOBBY, {
id: lobby.id || lobby,
});
}
connectToLobby(id, secret) {
return this.request(RPCCommands.CONNECT_TO_LOBBY, {
id,
secret,
});
}
sendToLobby(lobby, data) {
return this.request(RPCCommands.SEND_TO_LOBBY, {
id: lobby.id || lobby,
data,
});
}
disconnectFromLobby(lobby) {
return this.request(RPCCommands.DISCONNECT_FROM_LOBBY, {
id: lobby.id || lobby,
});
}
updateLobbyMember(lobby, user, metadata) {
return this.request(RPCCommands.UPDATE_LOBBY_MEMBER, {
lobby_id: lobby.id || lobby,
user_id: user.id || user,
metadata,
});
}
getRelationships() {
const types = Object.keys(RelationshipTypes);
return this.request(RPCCommands.GET_RELATIONSHIPS)
.then((o) => o.relationships.map((r) => ({
...r,
type: types[r.type],
})));
}
/**
* Subscribe to an event
* @param {string} event Name of event e.g. `MESSAGE_CREATE`
* @param {Object} [args] Args for event e.g. `{ channel_id: '1234' }`
* @returns {Promise<Object>}
*/
async subscribe(event, args) {
await this.request(RPCCommands.SUBSCRIBE, args, event);
return {
unsubscribe: () => this.request(RPCCommands.UNSUBSCRIBE, args, event),
};
}
/**
* Destroy the client
*/
async destroy() {
await this.transport.close();
}
}
module.exports = RPCClient;

View File

@@ -0,0 +1,178 @@
'use strict';
function keyMirror(arr) {
const tmp = {};
for (const value of arr) {
tmp[value] = value;
}
return tmp;
}
exports.browser = typeof window !== 'undefined';
exports.RPCCommands = keyMirror([
'DISPATCH',
'AUTHORIZE',
'AUTHENTICATE',
'GET_GUILD',
'GET_GUILDS',
'GET_CHANNEL',
'GET_CHANNELS',
'CREATE_CHANNEL_INVITE',
'GET_RELATIONSHIPS',
'GET_USER',
'SUBSCRIBE',
'UNSUBSCRIBE',
'SET_USER_VOICE_SETTINGS',
'SET_USER_VOICE_SETTINGS_2',
'SELECT_VOICE_CHANNEL',
'GET_SELECTED_VOICE_CHANNEL',
'SELECT_TEXT_CHANNEL',
'GET_VOICE_SETTINGS',
'SET_VOICE_SETTINGS_2',
'SET_VOICE_SETTINGS',
'CAPTURE_SHORTCUT',
'SET_ACTIVITY',
'SEND_ACTIVITY_JOIN_INVITE',
'CLOSE_ACTIVITY_JOIN_REQUEST',
'ACTIVITY_INVITE_USER',
'ACCEPT_ACTIVITY_INVITE',
'INVITE_BROWSER',
'DEEP_LINK',
'CONNECTIONS_CALLBACK',
'BRAINTREE_POPUP_BRIDGE_CALLBACK',
'GIFT_CODE_BROWSER',
'GUILD_TEMPLATE_BROWSER',
'OVERLAY',
'BROWSER_HANDOFF',
'SET_CERTIFIED_DEVICES',
'GET_IMAGE',
'CREATE_LOBBY',
'UPDATE_LOBBY',
'DELETE_LOBBY',
'UPDATE_LOBBY_MEMBER',
'CONNECT_TO_LOBBY',
'DISCONNECT_FROM_LOBBY',
'SEND_TO_LOBBY',
'SEARCH_LOBBIES',
'CONNECT_TO_LOBBY_VOICE',
'DISCONNECT_FROM_LOBBY_VOICE',
'SET_OVERLAY_LOCKED',
'OPEN_OVERLAY_ACTIVITY_INVITE',
'OPEN_OVERLAY_GUILD_INVITE',
'OPEN_OVERLAY_VOICE_SETTINGS',
'VALIDATE_APPLICATION',
'GET_ENTITLEMENT_TICKET',
'GET_APPLICATION_TICKET',
'START_PURCHASE',
'GET_SKUS',
'GET_ENTITLEMENTS',
'GET_NETWORKING_CONFIG',
'NETWORKING_SYSTEM_METRICS',
'NETWORKING_PEER_METRICS',
'NETWORKING_CREATE_TOKEN',
'SET_USER_ACHIEVEMENT',
'GET_USER_ACHIEVEMENTS',
]);
exports.RPCEvents = keyMirror([
'CURRENT_USER_UPDATE',
'GUILD_STATUS',
'GUILD_CREATE',
'CHANNEL_CREATE',
'RELATIONSHIP_UPDATE',
'VOICE_CHANNEL_SELECT',
'VOICE_STATE_CREATE',
'VOICE_STATE_DELETE',
'VOICE_STATE_UPDATE',
'VOICE_SETTINGS_UPDATE',
'VOICE_SETTINGS_UPDATE_2',
'VOICE_CONNECTION_STATUS',
'SPEAKING_START',
'SPEAKING_STOP',
'GAME_JOIN',
'GAME_SPECTATE',
'ACTIVITY_JOIN',
'ACTIVITY_JOIN_REQUEST',
'ACTIVITY_SPECTATE',
'ACTIVITY_INVITE',
'NOTIFICATION_CREATE',
'MESSAGE_CREATE',
'MESSAGE_UPDATE',
'MESSAGE_DELETE',
'LOBBY_DELETE',
'LOBBY_UPDATE',
'LOBBY_MEMBER_CONNECT',
'LOBBY_MEMBER_DISCONNECT',
'LOBBY_MEMBER_UPDATE',
'LOBBY_MESSAGE',
'CAPTURE_SHORTCUT_CHANGE',
'OVERLAY',
'OVERLAY_UPDATE',
'ENTITLEMENT_CREATE',
'ENTITLEMENT_DELETE',
'USER_ACHIEVEMENT_UPDATE',
'READY',
'ERROR',
]);
exports.RPCErrors = {
CAPTURE_SHORTCUT_ALREADY_LISTENING: 5004,
GET_GUILD_TIMED_OUT: 5002,
INVALID_ACTIVITY_JOIN_REQUEST: 4012,
INVALID_ACTIVITY_SECRET: 5005,
INVALID_CHANNEL: 4005,
INVALID_CLIENTID: 4007,
INVALID_COMMAND: 4002,
INVALID_ENTITLEMENT: 4015,
INVALID_EVENT: 4004,
INVALID_GIFT_CODE: 4016,
INVALID_GUILD: 4003,
INVALID_INVITE: 4011,
INVALID_LOBBY: 4013,
INVALID_LOBBY_SECRET: 4014,
INVALID_ORIGIN: 4008,
INVALID_PAYLOAD: 4000,
INVALID_PERMISSIONS: 4006,
INVALID_TOKEN: 4009,
INVALID_USER: 4010,
LOBBY_FULL: 5007,
NO_ELIGIBLE_ACTIVITY: 5006,
OAUTH2_ERROR: 5000,
PURCHASE_CANCELED: 5008,
PURCHASE_ERROR: 5009,
RATE_LIMITED: 5011,
SELECT_CHANNEL_TIMED_OUT: 5001,
SELECT_VOICE_FORCE_REQUIRED: 5003,
SERVICE_UNAVAILABLE: 1001,
TRANSACTION_ABORTED: 1002,
UNAUTHORIZED_FOR_ACHIEVEMENT: 5010,
UNKNOWN_ERROR: 1000,
};
exports.RPCCloseCodes = {
CLOSE_NORMAL: 1000,
CLOSE_UNSUPPORTED: 1003,
CLOSE_ABNORMAL: 1006,
INVALID_CLIENTID: 4000,
INVALID_ORIGIN: 4001,
RATELIMITED: 4002,
TOKEN_REVOKED: 4003,
INVALID_VERSION: 4004,
INVALID_ENCODING: 4005,
};
exports.LobbyTypes = {
PRIVATE: 1,
PUBLIC: 2,
};
exports.RelationshipTypes = {
NONE: 0,
FRIEND: 1,
BLOCKED: 2,
PENDING_INCOMING: 3,
PENDING_OUTGOING: 4,
IMPLICIT: 5,
};

View File

@@ -0,0 +1,10 @@
'use strict';
const util = require('./util');
module.exports = {
Client: require('./client'),
register(id) {
return util.register(`discord-${id}`);
},
};

View File

@@ -0,0 +1,6 @@
'use strict';
module.exports = {
ipc: require('./ipc'),
websocket: require('./websocket'),
};

View File

@@ -0,0 +1,173 @@
'use strict';
const net = require('net');
const EventEmitter = require('events');
const fetch = require('node-fetch');
const { uuid } = require('../util');
const OPCodes = {
HANDSHAKE: 0,
FRAME: 1,
CLOSE: 2,
PING: 3,
PONG: 4,
};
function getIPCPath(id) {
if (process.platform === 'win32') {
return `\\\\?\\pipe\\discord-ipc-${id}`;
}
const { env: { XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP } } = process;
const prefix = XDG_RUNTIME_DIR || TMPDIR || TMP || TEMP || '/tmp';
return `${prefix.replace(/\/$/, '')}/discord-ipc-${id}`;
}
function getIPC(id = 0) {
return new Promise((resolve, reject) => {
const path = getIPCPath(id);
const onerror = () => {
if (id < 10) {
resolve(getIPC(id + 1));
} else {
reject(new Error('Could not connect'));
}
};
const sock = net.createConnection(path, () => {
sock.removeListener('error', onerror);
resolve(sock);
});
sock.once('error', onerror);
});
}
async function findEndpoint(tries = 0) {
if (tries > 30) {
throw new Error('Could not find endpoint');
}
const endpoint = `http://127.0.0.1:${6463 + (tries % 10)}`;
try {
const r = await fetch(endpoint);
if (r.status === 404) {
return endpoint;
}
return findEndpoint(tries + 1);
} catch (e) {
return findEndpoint(tries + 1);
}
}
function encode(op, data) {
data = JSON.stringify(data);
const len = Buffer.byteLength(data);
const packet = Buffer.alloc(8 + len);
packet.writeInt32LE(op, 0);
packet.writeInt32LE(len, 4);
packet.write(data, 8, len);
return packet;
}
const working = {
full: '',
op: undefined,
};
function decode(socket, callback) {
const packet = socket.read();
if (!packet) {
return;
}
let { op } = working;
let raw;
if (working.full === '') {
op = working.op = packet.readInt32LE(0);
const len = packet.readInt32LE(4);
raw = packet.slice(8, len + 8);
} else {
raw = packet.toString();
}
try {
const data = JSON.parse(working.full + raw);
callback({ op, data }); // eslint-disable-line callback-return
working.full = '';
working.op = undefined;
} catch (err) {
working.full += raw;
}
decode(socket, callback);
}
class IPCTransport extends EventEmitter {
constructor(client) {
super();
this.client = client;
this.socket = null;
}
async connect() {
const socket = this.socket = await getIPC();
socket.on('close', this.onClose.bind(this));
socket.on('error', this.onClose.bind(this));
this.emit('open');
socket.write(encode(OPCodes.HANDSHAKE, {
v: 1,
client_id: this.client.clientId,
}));
socket.pause();
socket.on('readable', () => {
decode(socket, ({ op, data }) => {
switch (op) {
case OPCodes.PING:
this.send(data, OPCodes.PONG);
break;
case OPCodes.FRAME:
if (!data) {
return;
}
if (data.cmd === 'AUTHORIZE' && data.evt !== 'ERROR') {
findEndpoint()
.then((endpoint) => {
this.client.request.endpoint = endpoint;
})
.catch((e) => {
this.client.emit('error', e);
});
}
this.emit('message', data);
break;
case OPCodes.CLOSE:
this.emit('close', data);
break;
default:
break;
}
});
});
}
onClose(e) {
this.emit('close', e);
}
send(data, op = OPCodes.FRAME) {
this.socket.write(encode(op, data));
}
async close() {
return new Promise((r) => {
this.once('close', r);
this.send({}, OPCodes.CLOSE);
this.socket.end();
});
}
ping() {
this.send(uuid(), OPCodes.PING);
}
}
module.exports = IPCTransport;
module.exports.encode = encode;
module.exports.decode = decode;

View File

@@ -0,0 +1,77 @@
'use strict';
const EventEmitter = require('events');
const { browser } = require('../constants');
// eslint-disable-next-line
const WebSocket = browser ? window.WebSocket : require('ws');
const pack = (d) => JSON.stringify(d);
const unpack = (s) => JSON.parse(s);
class WebSocketTransport extends EventEmitter {
constructor(client) {
super();
this.client = client;
this.ws = null;
this.tries = 0;
}
async connect() {
const port = 6463 + (this.tries % 10);
this.tries += 1;
this.ws = new WebSocket(
`ws://127.0.0.1:${port}/?v=1&client_id=${this.client.clientId}`,
browser ? undefined : { origin: this.client.options.origin },
);
this.ws.onopen = this.onOpen.bind(this);
this.ws.onclose = this.onClose.bind(this);
this.ws.onerror = this.onError.bind(this);
this.ws.onmessage = this.onMessage.bind(this);
}
onOpen() {
this.emit('open');
}
onClose(event) {
if (!event.wasClean) {
return;
}
this.emit('close', event);
}
onError(event) {
try {
this.ws.close();
} catch {} // eslint-disable-line no-empty
if (this.tries > 20) {
this.emit('error', event.error);
} else {
setTimeout(() => {
this.connect();
}, 250);
}
}
onMessage(event) {
this.emit('message', unpack(event.data));
}
send(data) {
this.ws.send(pack(data));
}
ping() {} // eslint-disable-line no-empty-function
close() {
return new Promise((r) => {
this.once('close', r);
this.ws.close();
});
}
}
module.exports = WebSocketTransport;

View File

@@ -0,0 +1,50 @@
'use strict';
let register;
try {
const { app } = require('electron');
register = app.setAsDefaultProtocolClient.bind(app);
} catch (err) {
try {
register = require('register-scheme');
} catch (e) {} // eslint-disable-line no-empty
}
if (typeof register !== 'function') {
register = () => false;
}
function pid() {
if (typeof process !== 'undefined') {
return process.pid;
}
return null;
}
const uuid4122 = () => {
let uuid = '';
for (let i = 0; i < 32; i += 1) {
if (i === 8 || i === 12 || i === 16 || i === 20) {
uuid += '-';
}
let n;
if (i === 12) {
n = 4;
} else {
const random = Math.random() * 16 | 0;
if (i === 16) {
n = (random & 3) | 0;
} else {
n = random;
}
}
uuid += n.toString(16);
}
return uuid;
};
module.exports = {
pid,
register,
uuid: uuid4122,
};

View File

@@ -1,20 +1,25 @@
const { app, BrowserWindow, BrowserView, globalShortcut, ipcMain, Tray, Menu, protocol, session } = require("electron"); const { app, BrowserWindow, BrowserView, globalShortcut, ipcMain, Tray, Menu, protocol, session, dialog } = require("electron");
const electronremote = require("@electron/remote/main"); const electronremote = require("@electron/remote/main");
//const asar = require('@electron/asar'); //const asar = require('@electron/asar');
const windowStateKeeper = require("electron-window-state"); const windowStateKeeper = require("./window-state/index");
const { setupTitlebar, attachTitlebarToWindow } = require("./titlebar/main"); const { setupTitlebar, attachTitlebarToWindow } = require("./titlebar/main");
const openAboutWindow = require("./about-window/src/index").default; const openAboutWindow = require("./about-window/src/index").default;
const badge = require('./badge'); const badge = require('./badge');
const nodeNotifier = require('node-notifier');
const contextMenu = require('./context-menu'); const contextMenu = require('./context-menu');
const autoUpdater = require('./utils/auto-update'); const autoUpdater = require('./utils/auto-update');
const loadCRX = require('./utils/loadCRX'); const loadCRX = require('./utils/loadCRX');
const userStyles = require('./utils/userStyles');
const log4js = require("log4js"); const log4js = require("log4js");
//const axios = require("axios");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const os = require("os"); const os = require("os");
require('v8-compile-cache'); require('v8-compile-cache');
// Load package.json and contributors.json
const packageJson = require(path.join(__dirname, '..', '..', 'package.json')); const packageJson = require(path.join(__dirname, '..', '..', 'package.json'));
const contributors = require(path.join(__dirname, 'contributors.json'));
// isUpdaing: // isUpdaing:
global.isUpdating = false; global.isUpdating = false;
@@ -35,8 +40,10 @@ global.paths = {
home: os.homedir(), home: os.homedir(),
temp: path.join(os.tmpdir(), global.appInfo.name), temp: path.join(os.tmpdir(), global.appInfo.name),
}; };
global.paths.updateDir = path.join(global.paths.data, 'update'); global.paths.user = path.join(global.paths.data, 'user');
global.paths.extensions = path.join(global.paths.data, 'extensions'); global.paths.updateDir = path.join(global.paths.user, 'update');
global.paths.extensions = path.join(global.paths.user, 'extensions');
global.paths.userstyles = path.join(global.paths.user, 'userstyles');
// URLs: // URLs:
global.urls = { global.urls = {
@@ -51,6 +58,36 @@ global.settings.account = `${global.settings.general}/account`;
global.settings.appearance = `${global.settings.general}/appearance`; global.settings.appearance = `${global.settings.general}/appearance`;
global.settings.privacy = `${global.settings.general}/privacy-and-security`; global.settings.privacy = `${global.settings.general}/privacy-and-security`;
// Check if app is run from the installer dmg (macOS)
if (process.platform === 'darwin' && app.isPackaged && !app.isInApplicationsFolder()) {
const response = dialog.showMessageBoxSync({
type: 'question',
buttons: ['Yes', 'No'],
title: 'Move to Applications folder',
message: 'Please move the app to the Applications folder to ensure it works correctly. Would you like to move it now?'
});
if (response === 0) { // User clicked 'Yes'
const appPath = app.getPath('exe');
const applicationsFolder = '/Applications';
const appName = path.basename(appPath);
const destinationPath = path.join(applicationsFolder, appName);
// Try to move the app
fs.rename(appPath, destinationPath, (err) => {
if (err) {
dialog.showErrorBox('Move Failed', 'Failed to move the app to the Applications folder.');
} else {
dialog.showInformationBox({ message: 'The app has been moved to the Applications folder. Please restart it.' });
app.quit();
}
});
} else {
dialog.showErrorBox('Move to Applications folder', 'Please move the app to the Applications folder to ensure it works correctly.');
app.quit();
}
}
// Badge options: // Badge options:
const badgeOptions = { const badgeOptions = {
fontColor: '#FFFFFF', // The font color fontColor: '#FFFFFF', // The font color
@@ -100,7 +137,7 @@ if (fs.existsSync(logFile) && !fs.existsSync(path.join(global.paths.data, 'lockf
fs.renameSync(logFile, path.join(global.paths.data, `${logFileName}.${mtime.toISOString().split('T')[0]}.log`)); fs.renameSync(logFile, path.join(global.paths.data, `${logFileName}.${mtime.toISOString().split('T')[0]}.log`));
}; };
}; };
logger.log("Starting Bsky Desktop"); logger.log(`Starting Bsky Desktop v${packageJson.version} on ${os.platform()} ${os.arch()}`);
// Create data directory if it does not exist: // Create data directory if it does not exist:
if (!fs.existsSync(global.paths.data)) { if (!fs.existsSync(global.paths.data)) {
@@ -114,10 +151,10 @@ if (!fs.existsSync(global.paths.temp)) {
fs.mkdirSync(global.paths.temp, { recursive: true }); fs.mkdirSync(global.paths.temp, { recursive: true });
}; };
// Create update directory if it does not exist: // Create user directory if it does not exist:
if (!fs.existsSync(global.paths.updateDir)) { if (!fs.existsSync(global.paths.data, 'user')) {
logger.info("Creating Update Directory"); logger.info("Creating User Directory");
fs.mkdirSync(global.paths.updateDir, { recursive: true }); fs.mkdirSync(global.paths.user, { recursive: true });
}; };
// Create extensions directory if it does not exist: // Create extensions directory if it does not exist:
@@ -126,6 +163,18 @@ if (!fs.existsSync(global.paths.extensions)) {
fs.mkdirSync(global.paths.extensions, { recursive: true }); fs.mkdirSync(global.paths.extensions, { recursive: true });
}; };
// Create userstyles directory if it does not exist:
if (!fs.existsSync(global.paths.userstyles)) {
logger.info("Creating Userstyles Directory");
fs.mkdirSync(global.paths.userstyles, { recursive: true });
};
// Create update directory if it does not exist:
if (!fs.existsSync(global.paths.updateDir)) {
logger.info("Creating Update Directory");
fs.mkdirSync(global.paths.updateDir, { recursive: true });
};
// improve performance on linux? // improve performance on linux?
if (process.platform !== "win32" && process.platform !== "darwin") { if (process.platform !== "win32" && process.platform !== "darwin") {
logger.log("Disabling Hardware Acceleration and Transparent Visuals"); logger.log("Disabling Hardware Acceleration and Transparent Visuals");
@@ -140,16 +189,16 @@ setupTitlebar();
// Disable reload and F5 if not in dev mode: // Disable reload and F5 if not in dev mode:
if (process.env.NODE_ENV !== 'development') { if (process.env.NODE_ENV !== 'development') {
app.on('browser-window-focus', function () { app.on('browser-window-focus', function () {
globalShortcut.register("CommandOrControl+R", () => { /*globalShortcut.register("CommandOrControl+R", () => {
//console.log("CommandOrControl+R is pressed: Shortcut Disabled"); //console.log("CommandOrControl+R is pressed: Shortcut Disabled");
}); });
globalShortcut.register("F5", () => { globalShortcut.register("F5", () => {
//console.log("F5 is pressed: Shortcut Disabled"); //console.log("F5 is pressed: Shortcut Disabled");
}); });*/
}); });
app.on('browser-window-blur', function () { app.on('browser-window-blur', function () {
globalShortcut.unregister('CommandOrControl+R'); /*globalShortcut.unregister('CommandOrControl+R');
globalShortcut.unregister('F5'); globalShortcut.unregister('F5');*/
}); });
}; };
@@ -212,14 +261,14 @@ function createWindow() {
PageView.setBounds({ PageView.setBounds({
x: 0, x: 0,
y: 30, y: 30,
width: mainWindow.getBounds().width, width: mainWindow.isMaximized() ? mainWindow.getBounds().width - 16 : mainWindow.getBounds().width,
height: mainWindow.getBounds().height - 30, height: mainWindow.getBounds().height - 30,
}); });
mainWindow.on("resize", () => { mainWindow.on("resize", () => {
PageView.setBounds({ PageView.setBounds({
x: 0, x: 0,
y: 30, y: 30,
width: mainWindow.getBounds().width, width: mainWindow.isMaximized() ? mainWindow.getBounds().width - 16 : mainWindow.getBounds().width,
height: mainWindow.getBounds().height - 30, height: mainWindow.getBounds().height - 30,
}); });
}); });
@@ -247,34 +296,6 @@ function createWindow() {
// Badge count: (use mainWindow as that shows the badge on the taskbar) // Badge count: (use mainWindow as that shows the badge on the taskbar)
new badge(mainWindow, badgeOptions); new badge(mainWindow, badgeOptions);
// Load extensions (.crx files):
logger.log("Checking for extensions");
const extensions = fs.readdirSync(global.paths.extensions).filter((file) => file.endsWith('.crx'));
if (extensions.length > 0) {
logger.log(`Unpacking ${extensions.length} extensions and loading them`);
extensions.forEach((extension) => {
loadCRX(path.join(global.paths.extensions, extension));
});
} else {
// Check for unpacked extensions:
const unpackedExtensions = fs.readdirSync(global.paths.extensions).filter((file) => fs.lstatSync(path.join(global.paths.extensions, file)).isDirectory());
// Check if the directory contains a manifest.json file
unpackedExtensions.forEach((extension) => {
const manifestPath = path.join(global.paths.extensions, extension, 'manifest.json');
if (fs.existsSync(manifestPath)) {
logger.log(`Loading unpacked extension: ${extension}`);
session.defaultSession.loadExtension(path.join(global.paths.extensions, extension)).then(({ id }) => {
logger.log(`Extension loaded with ID: ${id}`);
}).catch((error) => {
logger.error(`Failed to load extension: ${error}`);
});
} else {
logger.warn(`Skipping directory ${extension} as it does not contain a manifest.json file`);
};
});
};
logger.log("Main Window Created, Showing splashscreen"); logger.log("Main Window Created, Showing splashscreen");
splash.show(); splash.show();
//mainWindow.show(); //mainWindow.show();
@@ -302,7 +323,7 @@ function createWindow() {
}; };
}); });
PageView.webContents.setWindowOpenHandler(({ url }) => { PageView.webContents.setWindowOpenHandler(({ url }) => {
new BrowserWindow({ show: true, autoHideMenuBar: true }).loadURL(url); new BrowserWindow({ show: true, autoHideMenuBar: true, icon: path.join(global.paths.app, 'ui', 'images', 'logo.png') }).loadURL(url);
return { action: 'deny' }; return { action: 'deny' };
}); });
// Log PageView navigations: // Log PageView navigations:
@@ -322,6 +343,7 @@ function showAboutWindow() {
//open_devtools: process.env.NODE_ENV !== 'production', //open_devtools: process.env.NODE_ENV !== 'production',
use_version_info: [ use_version_info: [
['Application Version', `${global.appInfo.version}`], ['Application Version', `${global.appInfo.version}`],
['Contributors', contributors.map((contributor) => contributor.name).join(', ')],
], ],
license: `MIT, GPL-2.0, GPL-3.0, ${global.appInfo.license}`, license: `MIT, GPL-2.0, GPL-3.0, ${global.appInfo.license}`,
}); });
@@ -329,7 +351,7 @@ function showAboutWindow() {
function createTray() { function createTray() {
logger.log("Creating Tray"); logger.log("Creating Tray");
const tray = new Tray(path.join(global.paths.app, 'ui', 'images', 'logo.png')); const tray = new Tray(path.join(global.paths.app, 'ui', 'images', 'icons', '32x32.png'));
tray.setToolTip('Bsky Desktop'); tray.setToolTip('Bsky Desktop');
tray.setContextMenu(Menu.buildFromTemplate([ tray.setContextMenu(Menu.buildFromTemplate([
{ label: global.appInfo.name, enabled: false }, { label: global.appInfo.name, enabled: false },
@@ -405,7 +427,7 @@ function handleDeeplink(commandLine) {
break; break;
case "notiftest": case "notiftest":
global.PageView.webContents.send('ui:notif', { title: 'Updater', message: 'Update downloaded', options: { position: 'topRight', timeout: 5000, layout: 2, color: 'blue' } }); global.PageView.webContents.send('ui:notif', { title: 'Updater', message: 'Update downloaded', options: { izitoast: { position: 'topRight', timeout: 5000, layout: 2, color: 'blue' } } });
break; break;
default: default:
@@ -516,17 +538,198 @@ app.whenReady().then(() => {
// Set session: // Set session:
global.session = ses; global.session = ses;
// Initialize the updater:
logger.log("Initializing Updater");
autoUpdater();
// Handle ipc for render: // Handle ipc for render:
ipcMain.on('close-app', (event, arg) => { ipcMain.on('close-app', (event, arg) => {
app.quit(); app.quit();
}); });
ipcMain.on('app:restart', (event, arg) => {
app.relaunch();
app.quit();
});
// Create windows and tray:
createWindow(); createWindow();
createTray(); createTray();
// Wait for splash screen to load before checking for updates, loading extensions and userstyles
global.splash.webContents.on('did-finish-load', async () => {
// Check for internet connection:
logger.log("Checking for internet connection");
require('dns').lookup('google.com', err => {
if (err) {
logger.log('No internet connection, showing not connected message');
global.PageView.webContents.loadFile(path.join(global.paths.app, 'ui', 'offline.html'));
return;
}
logger.log('Internet available, checking for updates');
// Initialize the updater:
logger.log("Initializing Updater");
global.splash.webContents.send('ui:progtext', { title: 'Checking for updates...', subtitle: 'Awaiting response' });
autoUpdater.checkForUpdates().then(async (result) => {
//console.log(result);
switch (result.code) {
case 'update-available':
global.isUpdating = true;
logger.log("Update available, downloading");
global.splash.webContents.send('ui:progtext', { title: 'Update available', subtitle: 'Downloading update' });
try {
const update = await autoUpdater.downloadUpdate();
//console.log(update);
if (update.err && update.err.code === 'unpackaged') {
logger.warn("Update failed to download, unpackaged app");
global.splash.webContents.send('ui:progtext', { title: 'Not downloading update', subtitle: 'Unpackaged app' });
global.isUpdating = false;
} else {
if (update.err) {
logger.error("Update failed to download");
console.log(update.err);
global.splash.webContents.send('ui:progtext', { title: 'Failed to download update', subtitle: 'Continuing as normal...' });
};
switch (update.code) {
case 'downloaded':
logger.log("Update downloaded, installing");
global.splash.webContents.send('ui:progtext', { title: 'Update downloaded', subtitle: 'Installing update' });
try {
const install = await autoUpdater.installUpdate(update.path);
//console.log(install);
if (install.err) {
logger.error("Failed to install update");
console.log(install.err);
global.splash.webContents.send('ui:progtext', { title: 'Failed to install update', subtitle: 'Continuing as normal...' });
} else {
if (install.code === 'update-installed') {
logger.log("Update installed, restarting");
global.splash.webContents.send('ui:progtext', { title: 'Update installed', subtitle: 'Restarting...' });
app.relaunch();
app.quit();
} else {
logger.error("Failed to install update");
global.splash.webContents.send('ui:progtext', { title: 'Failed to install update', subtitle: 'Continuing as normal...' });
};
};
} catch (error) {
logger.error(`Error installing update:`, error);
global.splash.webContents.send('ui:progtext', { title: 'Error installing update', subtitle: ' ' });
};
break;
case 'download-failed':
logger.error("Failed to download update");
global.splash.webContents.send('ui:progtext', { title: 'Failed to download update', subtitle: 'Continuing as normal...' });
global.isUpdating = false;
global.mainWindow.show();
global.splash.destroy();
break;
default:
logger.error("Unknown update download status");
global.splash.webContents.send('ui:progtext', { title: 'Unknown update download status', subtitle: ' ' });
break;
};
};
} catch (error) {
logger.error(`Error downloading update:`, error);
global.splash.webContents.send('ui:progtext', { title: 'Error downloading update', subtitle: ' ' });
};
break;
case 'no-update':
logger.log("No update available");
global.splash.webContents.send('ui:progtext', { title: 'Checking for updates...', subtitle: 'Up to date!' });
break;
case 'check-failed':
logger.warn("Failed to check for updates");
global.splash.webContents.send('ui:progtext', { title: 'Failed to check for updates', subtitle: ' ' });
break;
case 'old-os':
logger.warn("Old OS, unable to update");
global.splash.webContents.send('ui:progtext', { title: 'Update not available', subtitle: 'Old OS' });
break;
default:
logger.warn("Unknown update status");
global.splash.webContents.send('ui:progtext', { title: 'Unknown update status', subtitle: ' ' });
break;
};
}).catch((error) => {
logger.error(`Error checking for updates: ${error}`);
global.splash.webContents.send('ui:progtext', { title: 'Error checking for updates', subtitle: ' ' });
}).finally(async () => {
// Load extensions (.crx files):
logger.log("Checking for extensions");
global.splash.webContents.send('ui:progtext', { title: 'Checking for extensions', subtitle: ' ' });
const extensions = fs.readdirSync(global.paths.extensions).filter((file) => file.endsWith('.crx'));
if (extensions.length > 0) {
logger.log(`Unpacking ${extensions.length} extensions and loading them`);
global.splash.webContents.send('ui:progtext', { title: `Unpacking ${extensions.length} extensions` });
extensions.forEach((extension) => {
logger.log(`Loading extension: ${extension}`);
global.splash.webContents.send('ui:progtext', { title: `Loading extension: ${extension}` });
loadCRX(path.join(global.paths.extensions, extension));
});
} else {
// Check for unpacked extensions:
const unpackedExtensions = fs.readdirSync(global.paths.extensions).filter((file) => fs.lstatSync(path.join(global.paths.extensions, file)).isDirectory());
// Check if the directory contains a manifest.json file
unpackedExtensions.forEach((extension) => {
const manifestPath = path.join(global.paths.extensions, extension, 'manifest.json');
if (fs.existsSync(manifestPath)) {
logger.log(`Loading unpacked extension: ${extension}`);
global.splash.webContents.send('ui:progtext', { title: `Loading unpacked extension: ${extension}` });
session.defaultSession.loadExtension(path.join(global.paths.extensions, extension)).then(({ id }) => {
logger.log(`Extension loaded with ID: ${id}`);
}).catch((error) => {
logger.error(`Failed to load extension: ${error}`);
});
} else {
logger.warn(`Skipping directory ${extension} as it does not contain a manifest.json file`);
};
});
};
// Load userstyles
logger.log("Checking for userstyles");
global.splash.webContents.send('ui:progtext', { title: 'Checking for userstyles' });
const userStylesDir = path.join(global.paths.userstyles);
if (fs.existsSync(userStylesDir)) {
const files = fs.readdirSync(userStylesDir).filter((file) => file.endsWith('.css'));
if (files.length > 0) {
logger.log(`Loading ${files.length} userstyles`);
const userStylePromises = files.map(async file => {
const cssFile = path.join(userStylesDir, file);
// Parse the CSS file
try {
const cssContent = fs.readFileSync(cssFile, 'utf-8');
const result = await userStyles.parseCSS(cssContent);
logger.info(`Loading userstyle: ${result.metadata.name}`);
// Compile the userstyle
const compiled = await userStyles.compileStyle(result.css, result.metadata);
// Check if the site 'bsky.app' is defined
if (compiled.sites?.['bsky.app']) {
// Apply the userstyle to the PageView
await PageView.webContents.insertCSS(compiled.sites['bsky.app']);
logger.info(`Applied userstyle: ${result.metadata.name}`);
global.splash.webContents.send('ui:progtext', { title: `Applied userstyle: ${result.metadata.name}` });
} else {
if (compiled.error) {
logger.warn(`Error loading userstyle: ${compiled.error.message}`);
} else {
logger.warn(`Userstyle ${result.metadata.name} does not target 'bsky.app'`);
}
}
} catch (error) {
logger.error(`Error loading userstyle: ${file}`, error);
}
});
await Promise.all(userStylePromises);
}
}
});
});
});
} else { } else {
logger.log("Failed to get singleInstanceLock, Quitting"); logger.log("Failed to get singleInstanceLock, Quitting");
app.quit(); app.quit();

View File

@@ -90,7 +90,7 @@ class Updater extends EventEmitter {
}; };
this.options = Object.assign({}, def, options); this.options = Object.assign({}, def, options);
this.options.cacheDirectory = path.resolve(this.options.tmpdir, this.options.name); this.options.cacheDirectory = path.resolve(this.options.tmpdir, this.options.name);
this.options.headers['user-agent'] = this.options.headers['user-agent'] || 'asar-updater/v0.0.2 (https://github.com/zce/asar-updater)'; this.options.headers['user-agent'] = this.options.headers['user-agent'] || 'Mozilla/5.0 asar-updater/v0.3.3 (https://github.com/zce/asar-updater)';
fs.existsSync(this.options.cacheDirectory) || fs.mkdirSync(this.options.cacheDirectory); fs.existsSync(this.options.cacheDirectory) || fs.mkdirSync(this.options.cacheDirectory);
} }
@@ -207,7 +207,7 @@ class Updater extends EventEmitter {
quitAndInstall(timeout) { quitAndInstall(timeout) {
setTimeout(() => { setTimeout(() => {
app.relaunch({ args: process.argv.slice(1) + ['--relaunch'] }); app.relaunch({ args: process.argv.slice(1) + ['--relaunch', '--update-installed'] });
app.exit(0); app.exit(0);
}, timeout || 100); }, timeout || 100);
} }

View File

@@ -1,9 +1,11 @@
const path = require('path');
const os = require('os');
const fs = require('fs');
const childProcess = require('child_process'); const childProcess = require('child_process');
const { app } = require('electron'); const { app } = require('electron');
const log4js = require('log4js'); const log4js = require('log4js');
const SemVer = require('semver');
const axios = require('axios');
const path = require('path');
const os = require('os');
const fs = require('fs');
// import the asarUpdater module // import the asarUpdater module
const asarUpdater = require('./asarUpdater'); const asarUpdater = require('./asarUpdater');
@@ -14,9 +16,73 @@ const SystemInfo = require('./sysInfo');
// Get the current system platform // Get the current system platform
const sys = new SystemInfo(); const sys = new SystemInfo();
// Detect the app install type
const installType = require('./installType');
// Setup the logger // Setup the logger
const logger = log4js.getLogger("bskydesktop"); const logger = log4js.getLogger("bskydesktop");
// System architecture / Platform
const systemArch = sys.isARM64() ? 'arm64' : sys.isX64() ? '64' : sys.isX86() ? '86' : 'unknown';
const systemPlatform = sys.isWin() ? 'win' : sys.isMac() ? 'mac' : sys.isLinux() ? 'linux' : 'unknown';
// Download url: (arch, platform, installer)
const downloadUrl = 'https://cdn.oxmc.me/apps/bsky-desktop/dl';
// Installer path (used to save the installer)
var installerPath = '';
var installerType = '';
async function detectInstallerType() {
let installerType = 'unknown';
// First check if the app is paclaged
if (!app.isPackaged) {
logger.warn('App is not packaged, cannot detect installer type.');
return 'unpackaged';
}
try {
// Check if system is Windows
if (sys.isWin()) {
//console.log('Checking for Windows installer type...');
const res = await installType.getUninstallEntries({ DisplayName: 'bskyDesktop' });
//console.log(res);
// Ensure result is valid before checking properties
if (res.length > 0) {
installerType = res[0].WindowsInstaller === 'MSI' ? 'msi' : 'exe';
} else {
// Default to exe if no installer type is found
installerType = 'exe';
}
}
// Check if system is macOS
if (sys.isMac()) {
const res = await installType.checkAppInstallationMethod();
switch (res.type) { // Use res.type instead of res directly
case 'MAS':
installerType = 'mas';
break;
case 'PKG':
installerType = 'pkg';
break;
case 'ZIP/DMG':
installerType = 'dmg';
break;
}
}
} catch (err) {
logger.error('Failed to detect installer type:', err);
}
return installerType;
}
// Asar updater
function asarUpdate() { function asarUpdate() {
asarUpdater.init(); asarUpdater.init();
@@ -62,7 +128,7 @@ function asarUpdate() {
// Set the feed URL (only works in packaged app): // Set the feed URL (only works in packaged app):
if (app.isPackaged) { if (app.isPackaged) {
logger.log("Setting Feed URL for app.asar"); logger.log("Setting Feed URL for app.asar");
asarUpdater.setFeedURL(path.join(global.paths.app_root), 'https://cdn.oxmc.me/internal/bsky-desktop/update/core'); asarUpdater.setFeedURL(path.join(global.paths.app_root), 'https://cdn.oxmc.me/apps/bsky-desktop/update/core');
}; };
//Check for updates: //Check for updates:
@@ -70,88 +136,379 @@ function asarUpdate() {
if (app.isPackaged) { if (app.isPackaged) {
const UPDATE_CHECK = 1000 * 60 * 60 * 4 // 4 hours const UPDATE_CHECK = 1000 * 60 * 60 * 4 // 4 hours
setInterval(() => { setInterval(() => {
//asarUpdater.checkForUpdates(); asarUpdater.checkForUpdates();
}, UPDATE_CHECK); }, UPDATE_CHECK);
//asarUpdater.checkForUpdates(); asarUpdater.checkForUpdates();
} else { } else {
logger.warn("Not checking for updates as app is not packaged"); logger.warn("Not checking for updates as app is not packaged");
}; };
} }
function checkForUpdates() { // Check for updates
// Current system information async function checkForUpdates() {
logger.log('Current system information:', sys.platform, sys.getVersion()); return new Promise((resolve, reject) => {
// Current system information
logger.log('Current system information:', sys.platform, sys.getVersion(), sys.arch);
// Check if the current system is Windows // Check if the current system is Windows
if (sys.isWin()) { if (sys.isWin()) {
// Check if the system is before Windows 10 // Check if the system is before Windows 10
if (sys.earlierThan('10.0.0')) { if (sys.earlierThan('10.0.0')) {
// Windows 10 and above are supported, but windows 7 and 8 are not supported // Windows 10 and above are supported, but windows 7 and 8 are not supported
logger.error('Windows 7 and 8 are not supported, please upgrade to Windows 10 or above, not updating...'); logger.error('Windows 7 and 8 are not supported, please upgrade to Windows 10 or above, not updating...');
} else { resolve({ err: 'old-os', msg: 'Windows 7 and 8 are not supported, please upgrade to Windows 10 or above, not updating...' });
// Check for updates, and if there are updates, download and install them
logger.log('Checking for updates (win)...');
}
}
// Check if the current system is macOS
if (sys.isMac()) {
let macArch = '';
// Check the current version of macOS, and whether we can use the pkg installer
if (sys.laterThan('10.0.0')) {
// Check if system is after macOS 10 (11, 12, etc.)
if (sys.laterThan('11.0.0')) {
// macOS 11 and above support ARM64, check if system is ARM64
logger.log('Checkking system architecture...');
if (sys.isARM64()) {
// System is ARM64 (mac-arm64)
macArch = 'arm64';
} else {
// System is x64 (mac-x64)
macArch = 'x64';
}
} else { } else {
// macOS 10 is mostly x64, but some versions are ARM64 // Check for updates, and if there are updates, download and install them
macArch = 'x64'; logger.log('Checking for updates (win)...');
axios.get('https://cdn.oxmc.me/apps/bsky-desktop/update/core').then((res) => {
//console.log(res.data);
const latestVersion = res.data.version;
const currentVersion = app.getVersion();
if (SemVer.gt(latestVersion, currentVersion)) {
//logger.log('Update available:', latestVersion);
global.PageView.webContents.send('ui:notif', JSON.stringify({ title: 'Update', message: 'An update is available' }));
if (global.splash) global.splash.webContents.send('ui:progtext', { title: 'Update Available', subtitle: 'An update is available! Downloading...' });
resolve({ code: 'update-available', msg: 'An update is available.', info: { latest: latestVersion, current: currentVersion, response: res.data } });
} else {
//logger.log('No updates available.');
resolve({ code: 'no-update', msg: 'No updates available.' });
}
}).catch((err) => {
logger.error('Failed to check for updates:', err);
resolve({ code: 'check-failed', msg: 'Failed to check for updates.' });
});
} }
logger.log('System architecture:', macArch);
// Check for updates, and if there are updates, download and install them
logger.log('Checking for updates (mac)...');
// Run the .pkg installer
const pkgPath = path.join(global.paths.updateDir, 'bsky-desktop.pkg');
const command = `sudo installer -pkg ${pkgPath} -target /`;
// Spawn a new shell
/*const shellProcess = spawn('sh', ['-c', command], {
stdio: 'inherit', // Pipe input/output to/from the shell
});
shellProcess.on('error', (err) => {
console.error('Failed to spawn shell:', err);
});
shellProcess.on('close', (code) => {
if (code === 0) {
console.log('Update installed successfully.');
} else {
console.error(`Shell process exited with code ${code}.`);
}
});*/
} else {
// macOS versions before 10 are not supported
logger.error('macOS versions before 10 are not supported, not updating...');
} }
}
// Check if the current system is Linux // Check if the current system is macOS
if (sys.isLinux()) { if (sys.isMac()) {
// Check for updates, and if there are updates, download and install them (no system version check) // Check the current version of macOS, and whether we can use the pkg installer
// Linux versions use AppImage, so we instead need to check for a new asar file so that the app can if (sys.laterThan('10.0.0')) {
// load the new asar instead of the packaged one (or even just delete the old appimage and download a new one) // Check for updates, and if there are updates, download and install them
logger.log('Checking for updates (linux)...'); logger.log('Checking for updates (mac)...');
asarUpdate();
} // Check for updates
axios.get('https://cdn.oxmc.me/apps/bsky-desktop/update/core').then((res) => {
//console.log(res.data);
const latestVersion = res.data.version;
const currentVersion = app.getVersion();
if (SemVer.gt(latestVersion, currentVersion)) {
//logger.log('Update available:', latestVersion);
global.PageView.webContents.send('ui:notif', JSON.stringify({ title: 'Update', message: 'An update is available' }));
if (global.splash) global.splash.webContents.send('ui:progtext', { title: 'Update Available', subtitle: 'An update is available! Downloading...' });
resolve({ code: 'update-available', msg: 'An update is available.', info: { latest: latestVersion, current: currentVersion, response: res.data } });
} else {
//logger.log('No updates available.');
resolve({ code: 'no-update', msg: 'No updates available.' });
}
}).catch((err) => {
logger.error('Failed to check for updates:', err);
resolve({ code: 'check-failed', msg: 'Failed to check for updates.' });
});
} else {
// macOS versions before 10 are not supported
logger.error('macOS versions before 10 are not supported, not updating...');
resolve({ err: 'old-os', msg: 'macOS versions before 10 are not supported, not updating...' });
}
}
// Check if the current system is Linux
if (sys.isLinux()) {
// Check for updates, and if there are updates, download and install them (no system version check)
// Linux versions use AppImage, so we instead need to check for a new asar file so that the app can
// load the new asar instead of the packaged one (or even just delete the old appimage and download a new one)
logger.log('Checking for updates (linux)...');
asarUpdate();
}
}).catch((err) => {
logger.error('Failed to check for updates:', err);
});
} }
module.exports = checkForUpdates; // Download the update installer
async function downloadUpdate() {
return new Promise(async (resolve, reject) => {
// download the update installer
//logger.log('Downloading update...');
// Create the update directory if it doesn't exist
if (!fs.existsSync(global.paths.updateDir)) {
fs.mkdirSync(global.paths.updateDir);
}
// Get the installer name
const installerName = `bsky-desktop-${sys.platform}-${systemArch}.exe`;
installerPath = path.join(global.paths.updateDir, installerName);
// Detect the installer type
installerType = await detectInstallerType();
//installerType = 'exe'; // For testing purposes
//logger.info('Installer type:', installerType);
// if the installer type is unknown, return an error
if (installerType === 'unknown') {
//logger.error('Failed to detect installer type:', installerType);
reject({ code: 'unknown-installer', msg: 'Failed to detect installer type.', err: installerType });
return;
}
// If the installer type is unpackaged, return and continue
if (installerType === 'unpackaged') {
//logger.warn('App is not packaged, cannot download update.');
reject({ code: 'unpackaged', msg: 'App is not packaged, cannot download update.' });
return;
}
// download the installer as a stream
const installerStream = fs.createWriteStream(installerPath);
// download the installer
axios({
method: 'get',
url: downloadUrl,
responseType: 'stream',
headers: {
"x-platform": systemPlatform,
"x-arch": systemArch,
"x-installMethod": installerType
}
}).then((res) => {
// pipe the installer stream to the installer file
res.data.pipe(installerStream);
// close the installer stream
installerStream.on('finish', () => {
installerStream.close();
// resolve the promise
resolve({ code: 'downloaded', msg: 'Update downloaded successfully.', path: installerPath });
});
// handle errors
installerStream.on('error', (err) => {
//logger.error('Failed to download and save update:', err);
reject({ code: 'download-save-failed', msg: 'Failed to download and save update.', err: err });
});
// handle close
installerStream.on('close', () => {
//logger.log('Installer stream closed.');
});
}).catch((err) => {
logger.error('Failed to download and save update:', err);
reject({ code: 'download-failed', msg: 'Failed to download update.', err: err });
});
}).catch((err) => {
//logger.error('Failed to download update:', err);
return { code: 'download-failed', msg: 'Failed to download update.', err: err };
});
}
async function installUpdate(installer) {
return new Promise((resolve, reject) => {
try {
// Current system information
//logger.log('Current system information:', sys.platform, sys.getVersion(), sys.arch);
// Check if the current system is Windows
if (sys.isWin()) {
// Check if the system is before Windows 10
if (sys.earlierThan('10.0.0')) {
// Windows 10 and above are supported, but windows 7 and 8 are not supported
logger.error('Windows 7 and 8 are not supported, please upgrade to Windows 10 or above, not installing updating...');
reject({ err: 'old-os', msg: 'Windows 7 and 8 are not supported, please upgrade to Windows 10 or above, not installing update...' });
} else {
// Install the update
logger.log('Installing update (win)...');
// Check if the installer path is valid
installerPath = path.resolve(installer);
if (!fs.existsSync(installerPath)) {
logger.error('Installer path is invalid:', installerPath);
reject({ code: 'invalid-installer', msg: 'Installer path is invalid.', path: installerPath });
}
// Create the log file
const logStream = fs.createWriteStream(path.join(global.paths.updateDir, 'install.log'), { flags: 'a' });
// Run the installer (msi or exe)
switch (installerType) {
case 'msi':
// Run the msi installer /passive, /quiet, /qn, /norestart
const msiExec = childProcess.spawn('cmd.exe', ['/c', 'msiexec', '/i "${installerPath}"', '/qn', '/norestart', `/log ${path.join(global.paths.updateDir, 'installer-exec.log')}`], {
detached: true,
stdio: ['ignore', 'pipe', 'pipe']
});
msiExec.on('error', (err) => {
//console.error('Failed to start process:', err);
reject({ code: 'process-failed', msg: 'Failed to start installation process.', err: err });
});
msiExec.on('exit', (code, signal) => {
if (code === 0) {
//console.log('Update installed successfully.');
resolve({ code: 'update-installed', msg: 'Update installed successfully.' });
} else {
//console.error(`Installer process exited with code: ${code}, signal: ${signal}`);
reject({ code: 'install-failed', msg: 'Installer process failed.', exitCode: code, signal: signal });
}
});
msiExec.stdout.on('data', (data) => {
//console.log(`stdout: ${data}`);
logStream.write(data);
});
msiExec.stderr.on('data', (data) => {
//console.error(`stderr: ${data}`);
logStream.write(data);
});
app.quit();
break;
case 'exe':
// Run the exe installer /s
const exeExec = childProcess.spawn('cmd.exe', ['/c', installerPath], {
detached: true,
stdio: ['ignore', 'pipe', 'pipe']
});
exeExec.on('error', (err) => {
//console.error('Failed to start process:', err);
reject({ code: 'process-failed', msg: 'Failed to start installation process.', err: err });
});
exeExec.on('exit', (code, signal) => {
if (code === 0) {
//console.log('Update installed successfully.');
resolve({ code: 'update-installed', msg: 'Update installed successfully.' });
} else {
//console.error(`Installer process exited with code: ${code}, signal: ${signal}`);
reject({ code: 'install-failed', msg: 'Installer process failed.', exitCode: code, signal: signal });
}
});
exeExec.stdout.on('data', (data) => {
//console.log(`stdout: ${data}`);
logStream.write(data);
});
exeExec.stderr.on('data', (data) => {
//console.error(`stderr: ${data}`);
logStream.write(data);
});
app.quit();
break;
default:
//logger.error('Installer type is invalid:', installerType);
reject({ code: 'invalid-installer-type', msg: 'Installer type is invalid.', type: installerType });
break;
}
}
}
// Check if the current system is macOS
if (sys.isMac()) {
// Check the current version of macOS, and whether we can use the pkg installer
if (sys.laterThan('10.0.0')) {
// Install the update
logger.log('Installing update (mac)...');
// Check if the installer path is valid
installerPath = path.resolve(installer);
if (!fs.existsSync(installerPath)) {
logger.error('Installer path is invalid:', installerPath);
reject({ code: 'invalid-installer', msg: 'Installer path is invalid.', path: installerPath });
}
// Create the log file
const logStream = fs.createWriteStream(path.join(global.paths.updateDir, 'install.log'), { flags: 'a' });
// Run the .pkg installer
const command = `sudo installer -pkg ${installerPath} -target /`;
const shellProcess = childProcess.spawn('sh', ['-c', command], {
detached: true,
stdio: ['ignore', 'pipe', 'pipe']
});
shellProcess.on('error', (err) => {
//console.error('Failed to start process:', err);
reject({ code: 'process-failed', msg: 'Failed to start installation process.', err: err });
});
shellProcess.on('exit', (code, signal) => {
if (code === 0) {
//console.log('Update installed successfully.');
resolve({ code: 'update-installed', msg: 'Update installed successfully.' });
} else {
//console.error(`Installer process exited with code: ${code}, signal: ${signal}`);
reject({ code: 'install-failed', msg: 'Installer process failed.', exitCode: code, signal: signal });
}
});
shellProcess.stdout.on('data', (data) => {
//console.log(`stdout: ${data}`);
logStream.write(data);
});
shellProcess.stderr.on('data', (data) => {
//console.error(`stderr: ${data}`);
logStream.write(data);
});
} else {
// macOS versions before 10 are not supported
logger.error('macOS versions before 10 are not supported, not installing update...');
reject({ err: 'old-os', msg: 'macOS versions before 10 are not supported, not installing update...' });
}
}
// Check if the current system is Linux (This should be handled via asarUpdater)
if (sys.isLinux()) {
// Check for updates, and if there are updates, download and install them (no system version check)
// Linux versions use AppImage, so we instead need to check for a new asar file so that the app can
// load the new asar instead of the packaged one (or even just delete the old appimage and download a new one)
//logger.log('Checking for updates (linux)...');
logger.log('This is not implemented yet, please update manually.');
//asarUpdate();
}
} catch (err) {
logger.error('Failed to install update:', err);
reject({ code: 'install-failed', msg: 'Failed to install update.', err: err });
}
});
};
async function downloadAndInstall() {
return new Promise((resolve, reject) => {
// Download the update installer
downloadUpdate().then((res) => {
if (res.code === 'downloaded') {
// Install the update
installUpdate(res.path).then((res) => {
if (res.code === 'install-failed') {
//logger.error('Failed to install update:', res.err);
reject({ code: 'install-failed', msg: 'Failed to install update.', err: res.err });
} else {
//logger.log('Update installed successfully.');
resolve({ code: 'update-installed', msg: 'Update installed successfully.' });
}
});
} else {
//logger.error('Failed to download update:', res.err);
reject({ code: 'download-failed', msg: 'Failed to download update.', err: res.err });
}
}).catch((err) => {
//logger.error('Failed to download and install update:', err);
reject({ code: 'download-install-failed', msg: 'Failed to download and install update.', err: err });
});
});
}
module.exports = {
checkForUpdates,
downloadUpdate,
installUpdate,
downloadAndInstall
};

View File

@@ -0,0 +1,121 @@
const path = require('path');
const { exec } = require('child_process');
const os = require('os');
const fs = require('fs');
// Get system information
const SystemInfo = require('./sysInfo');
// Get the current system platform
const sys = new SystemInfo();
// Only import the registry package if the system is Windows
let Registry;
if (sys.platform === 'win32') {
Registry = require('winreg');
}
// Get the uninstall entries from the registry
async function getUninstallEntries(filter = {}) {
if (sys.platform !== 'win32') {
console.error('This function only works on Windows.');
return [];
}
const registryPaths = [
'HKLM\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Uninstall',
'HKLM\\\\Software\\\\Wow6432Node\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Uninstall'
];
let results = [];
for (const regPath of registryPaths) {
const regKey = new Registry({ hive: Registry.HKLM, key: regPath });
try {
const subkeys = await new Promise((resolve, reject) => {
regKey.keys((err, keys) => {
if (err) reject(err);
else resolve(keys);
});
});
for (const subkey of subkeys) {
const values = await new Promise((resolve, reject) => {
subkey.values((err, items) => {
if (err) reject(err);
else resolve(items);
});
});
let entry = {};
values.forEach(item => {
if (item.name === 'DisplayName') entry.DisplayName = item.value;
if (item.name === 'Publisher') entry.Publisher = item.value;
if (item.name === 'InstallDate') entry.InstallDate = item.value;
if (item.name === 'DisplayVersion') entry.DisplayVersion = item.value;
if (item.name === 'HelpLink') entry.HelpLink = item.value;
if (item.name === 'UninstallString') entry.UninstallString = item.value;
if (item.name === 'WindowsInstaller') entry.WindowsInstaller = item.value === '1' ? 'MSI' : 'EXE';
});
if (entry.DisplayName && entry.UninstallString) {
// If WindowsInstaller value is missing, check UninstallString
if (!entry.WindowsInstaller) {
entry.WindowsInstaller = entry.UninstallString.includes('msiexec') ? 'MSI' : 'EXE';
}
results.push(entry);
}
}
} catch (error) {
console.error(`Error accessing registry path ${regPath}:`, error);
}
}
results.sort((a, b) => a.DisplayName.localeCompare(b.DisplayName));
// Filter results based on the provided filter (e.g., { DisplayName: 'bskyDesktop' })
if (filter.DisplayName) {
return results.filter(entry => entry.DisplayName?.includes(filter.DisplayName));
}
//console.log(results);
return results;
}
async function checkAppInstallationMethod(appPath, bundleId) {
if (!fs.existsSync(appPath)) {
return { error: "App does not exist at the specified path." };
}
try {
// Check for Mac App Store receipt
const { stdout: masOutput } = await execPromise(`mdls -name kMDItemAppStoreReceiptURL "${appPath}"`);
if (!masOutput.includes("(null)")) {
return { type: "MAS" };
}
// Check for PKG installation
try {
const { stdout: pkgOutput } = await execPromise(`pkgutil --pkg-info "${bundleId}"`);
return { type: "PKG", stdout: pkgOutput };
} catch {
// Ignore error; means PKG is not found
}
// Check if manually installed (ZIP/DMG)
const receiptPath = path.join(appPath, 'Contents', '_MASReceipt', 'receipt');
if (!fs.existsSync(receiptPath)) {
return { type: "ZIP/DMG" };
}
} catch (error) {
return { error: error.message };
}
}
module.exports = {
getUninstallEntries,
checkAppInstallationMethod
};

View File

@@ -61,6 +61,16 @@ class SystemInfo {
return !this.laterThan(compareVersion); return !this.laterThan(compareVersion);
} }
// Get edition of the os
getEdition() {
if (this.isWin()) {
const edition = childProcess.execSync('wmic os get Caption').toString().trim();
return edition.split('\n')[1].trim();
} else {
return 'N/A';
}
}
// Private: Parse version strings (e.g., "10.0.19045" -> [10, 0, 19045]) // Private: Parse version strings (e.g., "10.0.19045" -> [10, 0, 19045])
_parseVersion(version) { _parseVersion(version) {
return version.split('.').map((num) => parseInt(num, 10) || 0); return version.split('.').map((num) => parseInt(num, 10) || 0);

260
src/app/utils/userStyles.js Normal file
View File

@@ -0,0 +1,260 @@
const usercssMeta = require('usercss-meta');
const less = require('less');
const stylus = require('stylus');
/**
* Extracts all global variable and mixin definitions from CSS.
* @param {string} css - The CSS string.
* @returns {string} The extracted global definitions.
*/
function extractGlobalDefinitions(css) {
const globalDefinitionRegex = /(@[\w-]+\s*(?:{[^}]*}|;))/g;
return (css.match(globalDefinitionRegex) || []).join('\n');
}
/**
* Extracts variable definitions from metadata.
* @param {object} metadata - Metadata containing variable definitions.
* @returns {object} A map of variable names to their default values.
*/
function extractMetadataVars(metadata) {
if (!metadata?.vars) return {};
return Object.fromEntries(
Object.entries(metadata.vars).map(([key, value]) => [key, value.default || value.value || null])
);
}
/**
* Extracts content enclosed within matching braces starting from a given position.
* @param {string} css - The CSS string.
* @param {number} startPos - The starting position to search for braces.
* @returns {object|null} The content and ending position of the matched braces.
*/
function extractBracedContent(css, startPos) {
const braceMatch = matchBraces(css, startPos);
if (!braceMatch) return null;
return {
content: css.substring(braceMatch.start + 1, braceMatch.end - 1).trim(),
end: braceMatch.end,
};
}
/**
* Matches a pair of braces in a string starting from a given position.
* @param {string} content - The string content.
* @param {number} start - The starting position to search for braces.
* @returns {object|null} The start and end positions of the matched braces.
*/
function matchBraces(content, start) {
const openBrace = content.indexOf('{', start);
if (openBrace === -1) return null;
let braceCount = 1, pos = openBrace + 1;
while (braceCount > 0 && pos < content.length) {
if (content[pos] === '{') braceCount++;
if (content[pos] === '}') braceCount--;
pos++;
}
return braceCount === 0 ? { start: openBrace, end: pos } : null;
}
/**
* Parses domain rules from the provided CSS string.
* @param {string} css - The CSS string.
* @param {number} startPos - The starting position to search for domain rules.
* @returns {object|null} The domains and the rule start position.
*/
function parseDomainRule(css, startPos) {
const domainRuleRegex = /@-moz-document\s+domain\(\s*'([^']+)'(?:\s*,\s*'([^']+)')*\s*\)/g;
domainRuleRegex.lastIndex = startPos;
const match = domainRuleRegex.exec(css);
if (!match) return null;
const domains = match[0]
.match(/['"][^'"]+['"]/g) // Extract all single- or double-quoted domain values
.map(domain => domain.replace(/['"]/g, '').trim()); // Remove quotes and trim whitespace
return {
domains,
ruleStart: match.index + match[0].length - 1,
};
}
/**
* Parses @-moz-document rules and extracts domain-specific CSS.
* @param {string} css - The CSS string.
* @returns {object} A map of domains to their associated CSS.
*/
function parseMozRules(css) {
const rules = {};
let currentPos = 0;
// Helper to extract global definitions (CSS outside @-moz-document)
const globalCode = extractGlobalDefinitions(css);
while (currentPos < css.length) {
// Match @-moz-document syntax and extract domains
const domainMatch = css.slice(currentPos).match(/@-moz-document\s+(.*?){/s);
if (!domainMatch) break;
const domainsStr = domainMatch[1];
const ruleStart = currentPos + domainMatch.index + domainMatch[0].length - 1;
// Extract the content inside the braces for this @-moz-document rule
const bracedContent = extractBracedContent(css, ruleStart);
if (!bracedContent) break;
// Parse the domains and add the CSS to each
const domainList = domainsStr.match(/domain\("([^"]+)"\)/g) || [];
const domains = domainList.map((d) => d.match(/domain\("([^"]+)"\)/)[1]);
for (const domain of domains) {
rules[domain] = `${globalCode}\n${bracedContent.content}`;
}
currentPos = bracedContent.end;
}
return rules;
}
/**
* Parses metadata from the provided CSS string.
* @param {string} css - The CSS string.
* @returns {object} Parsed metadata.
*/
function parseCSS(css) {
try {
const normalizedCss = css.replace(/\r\n?/g, '\n');
const nocommentsCss = normalizedCss.replace(/\/\*[\s\S]*?\*\//g, ''); // Remove comments
return {
...usercssMeta.parse(normalizedCss),
css: nocommentsCss,
};
} catch (error) {
throw new Error(`Failed to parse CSS metadata: ${error.message}`);
}
}
/**
* Compiles CSS code with a preprocessor if specified in the metadata.
* @param {string} code - The CSS code.
* @param {object} metadata - Metadata containing preprocessor information.
* @param {object} [userVars={}] - User-defined variables to override defaults.
* @returns {Promise<{compiledCss: string, sites: object}>} The compiled CSS code and domain-specific mapping.
*/
async function compileStyle(code, metadata, userVars = {}) {
try {
// Extract and merge variables
const vars = {
...extractMetadataVars(metadata),
...userVars
};
// Generate full code with user variables
const fullCode = [
'// User variables',
Object.entries(vars).map(([key, value]) => `@${key}: ${value};`).join('\n'),
'// Main code',
code
].join('\n\n');
let compiledCode;
switch (metadata?.preprocessor?.toLowerCase()) {
case 'less':
compiledCode = await compileLess(fullCode, vars);
break;
case 'stylus':
compiledCode = await compileStylus(fullCode, vars);
break;
case 'sass':
throw Error('SASS preprocessor not supported yet. Skipping compilation.');
case 'scss':
throw Error('SCSS preprocessor not supported yet. Skipping compilation.');
default:
compiledCode = code; // Return unmodified for plain CSS/unknown preprocessor
}
// Parse domain rules
const domainRules = parseMozRules(compiledCode);
// Compile each domain's CSS if needed
const compiledRules = {};
for (const [domain, css] of Object.entries(domainRules)) {
switch (metadata.preprocessor?.toLowerCase()) {
case 'less':
compiledRules[domain] = await compileLess(css, vars);
break;
case 'stylus':
compiledRules[domain] = await compileStylus(css, vars);
break;
default:
compiledRules[domain] = css;
}
}
// Combine all CSS
const combinedCss = Object.entries(compiledRules)
.map(([domain, compiledCss]) => `/* ${domain} */\n${compiledCss}`)
.join('\n\n');
return {
compiledCss: combinedCss,
sites: compiledRules, // Map of domains to their CSS
};
} catch (error) {
//console.error('Style compilation error:', error);
return {
error
};
}
}
/**
* Compiles LESS code to CSS.
* @param {string} code - The LESS code.
* @param {object} vars - User-defined variables.
* @returns {Promise<string>} The compiled CSS.
*/
async function compileLess(code, vars = {}) {
return new Promise((resolve, reject) => {
less.render(code, {
math: 'parens-division',
javascriptEnabled: true,
compress: false,
globalVars: vars
}, (err, output) => {
if (err) return reject(err);
resolve(output.css);
});
});
}
/**
* Compiles Stylus code to CSS.
* @param {string} code - The Stylus code.
* @returns {Promise<string>} The compiled CSS.
*/
async function compileStylus(code, vars = {}) {
console.log(vars, code);
return new Promise((resolve, reject) => {
var stlus = stylus(code);
stlus.set('compress', false);
for (const [key, value] of Object.entries(vars)) {
stlus.define(key, value);
}
stlus.render((err, output) => {
if (err) return reject(err);
resolve(output);
});
});
}
module.exports = {
parseCSS,
compileStyle
};

View File

@@ -0,0 +1,224 @@
'use strict';
const path = require('path');
const fs = require('fs');
const electron = require('electron');
let remote;
try {
remote = require('@electron/remote');
} catch (err) {
remote = electron.remote; // Fallback for older Electron versions
}
module.exports = function (options) {
const app = electron.app || (remote ? remote.app : null);
const screen = electron.screen || (remote ? remote.screen : null);
let state;
let winRef;
let stateChangeTimer;
const eventHandlingDelay = 100;
const config = Object.assign({
file: 'window-state.json',
path: app.getPath('userData'),
maximize: true,
fullScreen: true
}, options);
const fullStoreFileName = path.join(config.path, config.file);
function isNormal(win) {
return !win.isMaximized() && !win.isMinimized() && !win.isFullScreen();
}
function hasBounds() {
return state &&
Number.isInteger(state.x) &&
Number.isInteger(state.y) &&
Number.isInteger(state.width) && state.width > 0 &&
Number.isInteger(state.height) && state.height > 0;
}
function resetStateToDefault() {
const displayBounds = screen.getPrimaryDisplay().bounds;
// Reset state to default values on the primary display
state = {
width: config.defaultWidth || 800,
height: config.defaultHeight || 600,
x: 0,
y: 0,
displayBounds
};
}
function windowWithinBounds(bounds) {
return (
state.x >= bounds.x &&
state.y >= bounds.y &&
state.x + state.width <= bounds.x + bounds.width &&
state.y + state.height <= bounds.y + bounds.height
);
}
function ensureWindowVisibleOnSomeDisplay() {
const visible = screen.getAllDisplays().some(display => {
return windowWithinBounds(display.bounds);
});
if (!visible) {
// Window is partially or fully not visible now.
// Reset it to safe defaults.
return resetStateToDefault();
}
}
function validateState() {
const isValid = state && (hasBounds() || state.isMaximized || state.isFullScreen);
if (!isValid) {
state = null;
return;
}
if (hasBounds() && state.displayBounds) {
ensureWindowVisibleOnSomeDisplay();
}
}
function updateState(win) {
win = win || winRef;
if (!win || !state) return;
// Don't throw an error when window was closed
try {
const winBounds = win.getBounds();
if (isNormal(win)) {
state.x = winBounds.x;
state.y = winBounds.y;
state.width = winBounds.width;
state.height = winBounds.height;
}
state.isMaximized = win.isMaximized();
state.isFullScreen = win.isFullScreen();
state.displayBounds = screen.getDisplayMatching(winBounds).bounds;
} catch (err) { }
}
function saveState(win) {
// Update window state only if it was provided
if (win) {
updateState(win);
}
// Save state
try {
fs.mkdirSync(path.dirname(fullStoreFileName), { recursive: true });
fs.writeFileSync(fullStoreFileName, JSON.stringify(state));
} catch (err) {
// Don't care
}
}
function stateChangeHandler() {
// Handles both 'resize' and 'move'
clearTimeout(stateChangeTimer);
stateChangeTimer = setTimeout(() => saveState(winRef), eventHandlingDelay);
}
function closeHandler() {
updateState();
}
function closedHandler() {
// Unregister listeners and save state
unmanage();
saveState();
}
function manage(win) {
if (config.maximize && state.isMaximized) {
win.maximize();
}
if (config.fullScreen && state.isFullScreen) {
win.setFullScreen(true);
}
function resizeBrowserView() {
try {
const view = win.getBrowserView();
if (view) {
const windowBounds = win.getBounds(); // Full window size
const screenBounds = screen.getPrimaryDisplay().bounds; // Screen size
// Calculate the usable area by subtracting window chrome and any borders
const width = windowBounds.width; // Width of the full window
const height = windowBounds.height; // Height of the full window
// Ensure BrowserView fits within the screen size and window size
view.setBounds({
x: 0,
y: 0,
width: Math.min(width, screenBounds.width), // Prevent overflow horizontally
height: Math.min(height, screenBounds.height) // Prevent overflow vertically
});
}
} catch (err) {
// Ignore errors if BrowserView is not present
}
}
function stateChangeHandlerWithBrowserView() {
stateChangeHandler(); // Preserve original behavior
resizeBrowserView(); // Resize BrowserView if applicable
}
win.on('resize', stateChangeHandler);
win.on('move', stateChangeHandler);
win.on('close', closeHandler);
win.on('closed', closedHandler);
// Initial BrowserView resize in case window is already open
resizeBrowserView();
winRef = win;
}
function unmanage() {
if (winRef) {
winRef.removeListener('resize', stateChangeHandler);
winRef.removeListener('move', stateChangeHandler);
clearTimeout(stateChangeTimer);
winRef.removeListener('close', closeHandler);
winRef.removeListener('closed', closedHandler);
winRef = null;
}
}
// Load previous state
try {
state = JSON.parse(fs.readFileSync(fullStoreFileName, 'utf8'));
} catch (err) {
// Don't care
}
// Check state validity
validateState();
// Set state fallback values
state = Object.assign({
width: config.defaultWidth || 800,
height: config.defaultHeight || 600
}, state || {});
return {
get x() { return state.x; },
get y() { return state.y; },
get width() { return state.width; },
get height() { return state.height; },
get displayBounds() { return state.displayBounds; },
get isMaximized() { return state.isMaximized; },
get isFullScreen() { return state.isFullScreen; },
saveState,
unmanage,
manage,
resetStateToDefault
};
};

View File

@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2015 Jakub Szwacz
Copyright (c) Marcel Wiehle <marcel@wiehle.me> (http://marcel.wiehle.me)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

105
src/ui/css/offline.css Normal file
View File

@@ -0,0 +1,105 @@
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden; /* Prevent overflow from the body */
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
background-color: #082032;
color: white;
}
.container {
display: flex;
justify-content: center;
align-items: flex-start; /* Align items to the top */
height: 100vh; /* Full viewport height */
overflow-y: auto; /* Allow vertical scrolling */
margin: 0;
}
.divround {
background-color: #2c394b;
height: auto; /* Let it grow with content */
width: 90vw; /* or set a max-width if preferred */
max-width: 510px; /* Fixed width for your content */
margin: auto;
padding: 2.375vh; /* Converted from 19px to vh */
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0, 0, 0, 0.02);
text-align: center;
overflow-y: auto; /* Allow scrolling inside the element if needed */
}
.circular {
border-radius: 125px;
-webkit-border-radius: 125px;
-moz-border-radius: 125px;
background-size: cover;
display: inline-block;
width: 250px;
height: 250px;
}
.btn {
background-color: DodgerBlue;
border: none;
color: white;
padding: 10px 20px;
cursor: pointer;
font-size: 20px;
border-radius: 8px;
}
/* Non-rounded border */
.btn.nr {
border-radius: 0px;
}
/* Full width */
.btn.fw {
width: 100%
}
/* Darker background on mouse-over */
.btn:hover {
background-color: RoyalBlue;
}
/* Success btn */
.btn.success {
background-color: #a6efb8e6;
}
/* Danger btn */
.btn.danger {
background-color: #ffafb4e6;
}
/* Warning btn */
.btn.warning {
background-color: #ffefb4e6;
}
/* Info btn */
.btn.info {
background-color: #b4e4f9e6;
}
/* button (input style) */
.btn.input {
background-color: rgb(82, 124, 165);
}
/* Darker on mouse-over */
.btn.input:hover {
background-color: rgb(72, 109, 146);
}
/* Darker on active */
.btn.input:active {
background-color: rgb(66, 111, 155);
}

View File

@@ -6,6 +6,7 @@
-webkit-user-drag: none; -webkit-user-drag: none;
/* Disable selection */ /* Disable selection */
-webkit-user-select: none; -webkit-user-select: none;
user-select: none;
} }
html, html,

View File

@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin:auto;display:block;" width="150px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin:auto;display:block;" width="150px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<circle cx="50" cy="50" r="30" stroke="#2B87D3" stroke-width="10" fill="none"></circle> <circle cx="50" cy="50" r="30" stroke="#2B87D3" stroke-width="10" fill="none"></circle>
<circle cx="50" cy="50" r="30" stroke="#0C396A" stroke-width="8" stroke-linecap="round" fill="none"> <circle cx="50" cy="50" r="30" stroke="#0C396A" stroke-width="8" stroke-linecap="round" fill="none">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;180 50 50;720 50 50" keyTimes="0;0.5;1"></animateTransform> <animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;180 50 50;720 50 50" keyTimes="0;0.5;1"></animateTransform>
<animate attributeName="stroke-dasharray" repeatCount="indefinite" dur="1s" values="18.84955592153876 169.64600329384882;94.2477796076938 94.24777960769377;18.84955592153876 169.64600329384882" keyTimes="0;0.5;1"></animate> <animate attributeName="stroke-dasharray" repeatCount="indefinite" dur="1s" values="18.84955592153876 169.64600329384882;94.2477796076938 94.24777960769377;18.84955592153876 169.64600329384882" keyTimes="0;0.5;1"></animate>
</circle> </circle>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 812 B

BIN
src/ui/images/mac.icns Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="60" height="60" style="box-sizing: border-box; padding: 3px; overflow: visible; color: #0071c2;">
<circle cx="60" cy="60" r="50" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" style="transform-origin: center;">
<animateTransform attributeName="transform" type="rotate" values="-90;810" keyTimes="0;1" dur="2s" repeatCount="indefinite"></animateTransform>
<animate attributeName="stroke-dashoffset" values="0%;0%;-157.080%" calcMode="spline" keySplines="0.61, 1, 0.88, 1; 0.12, 0, 0.39, 0" keyTimes="0;0.5;1" dur="2s" repeatCount="indefinite"></animate>
<animate attributeName="stroke-dasharray" values="0% 314.159%;157.080% 157.080%;0% 314.159%" calcMode="spline" keySplines="0.61, 1, 0.88, 1; 0.12, 0, 0.39, 0" keyTimes="0;0.5;1" dur="2s" repeatCount="indefinite"></animate>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 920 B

File diff suppressed because one or more lines are too long

11
src/ui/lib/css/stylelint-bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

63
src/ui/lib/css/stylus-renderer.min.js vendored Normal file

File diff suppressed because one or more lines are too long

31
src/ui/offline.html Normal file
View File

@@ -0,0 +1,31 @@
<html>
<head>
<title>No internet connection</title>
<link rel="stylesheet" href="./css/offline.css">
<link rel="stylesheet" href="./css/fa/6.7.1/css/all.min.css">
</head>
<body>
<div class="container">
<div class="divround" style="width: 510px;">
<h1>No internet connection</h1>
<p>We couldn't detect an internet connection. Please check your network and try again.</p>
<button class="btn" id="refresh-app"><i class="fa fa-refresh"></i> Reload</button>
</div>
</div>
<script>
// When the reload button is clicked, send a message to the main process to reload the app
document.getElementById('refresh-app').addEventListener('click', () => {
// Check for internet connection before reloading
if (navigator.onLine) {
window.ipc.send('app:restart');
return;
} else {
alert('Please check your network and try again.');
};
});
</script>
</body>
</html>

View File

@@ -1,33 +1,37 @@
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
// Expose IPC methods // Expose protected methods that allow the renderer process to use
const allowedChannels = ["ui:badgeCount", "ui:notif", "ui:settings", "ui:openSettings", "app:restart"];
// Expose ipcRenderer to the renderer process
contextBridge.exposeInMainWorld("ipc", { contextBridge.exposeInMainWorld("ipc", {
send: (channel, data) => { send: (channel, data) => {
ipcRenderer.send(channel, data); if (allowedChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
}, },
on: (channel, callback) => { on: (channel, callback) => {
ipcRenderer.on(channel, (event, ...args) => callback(...args)); if (allowedChannels.includes(channel)) {
ipcRenderer.on(channel, (event, ...args) => callback(...args));
}
}, },
}); });
contextBridge.exposeInMainWorld("badge", { // Load a script asynchronously
update: (badgeNumber) => { function loadScriptAsync(src) {
ipcRenderer.send('ui:badgeCount', badgeNumber); return new Promise((resolve, reject) => {
} const script = document.createElement("script");
}); script.src = src;
script.onload = () => {
// Helper to dynamically load a script and append it to the DOM console.log(`Loaded script: ${src}`);
function loadScript(src, callback) { resolve();
const script = document.createElement('script'); };
script.src = src; script.onerror = (error) => {
script.onload = () => { console.error(`Failed to load script: ${src}`, error);
console.log(`Loaded script: ${src}`); reject(error);
if (callback) callback(); };
}; document.body.appendChild(script);
script.onerror = (error) => { });
console.error(`Failed to load script: ${src}`, error);
};
document.body.appendChild(script);
} }
// Inject CSS into the DOM // Inject CSS into the DOM
@@ -39,18 +43,19 @@ function injectCSS(href) {
console.log(`Injected CSS: ${href}`); console.log(`Injected CSS: ${href}`);
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener("DOMContentLoaded", async () => {
// Inject CSS injectCSS("ui:///lib/izitoast.min.css");
injectCSS('ui:///lib/izitoast.min.css'); injectCSS("ui:///rend/extra-themes.css");
injectCSS('ui:///rend/extra-themes.css'); injectCSS("ui:///css/fa/6.7.1/css/all.min.css");
injectCSS('ui:///css/fa/6.7.1/css/all.min.css');
// Load jQuery first try {
loadScript('ui:///lib/jquery-3.3.1.min.js', () => { await loadScriptAsync("ui:///lib/jquery-3.3.1.min.js");
// Load iziToast after jQuery await loadScriptAsync("ui:///lib/confetti-1.9.3-browser.min.js");
loadScript('ui:///lib/izitoast.min.js', () => { await loadScriptAsync("ui:///lib/izitoast.min.js");
loadScript('ui:///rend/register-handles.js'); await loadScriptAsync("ui:///rend/register-handles.js");
loadScript('ui:///rend/bsky-ext.js'); await loadScriptAsync("ui:///rend/bsky-ext.js");
}); await loadScriptAsync("ui:///rend/specialAnimations.js");
}); } catch (error) {
}); console.error("Failed to load one or more scripts.", error);
}
});

View File

@@ -20,7 +20,7 @@ document.addEventListener('DOMContentLoaded', () => {
message: event.message, message: event.message,
position: 'topRight', position: 'topRight',
timeout: 5000, timeout: 5000,
...event.options, ...event.options.izitoast,
}); });
} catch (error) { } catch (error) {
console.error('Failed to display notification:', error); console.error('Failed to display notification:', error);
@@ -36,6 +36,8 @@ document.addEventListener('DOMContentLoaded', () => {
ipcRenderer.on('ui:progtext', (event, other) => { ipcRenderer.on('ui:progtext', (event, other) => {
console.log('Updating progress text:', other); console.log('Updating progress text:', other);
infotext.innerText = other.title; infotext.innerText = other.title;
progtext.innerText = other.subtitle; if (other.subtitle) {
progtext.innerText = other.subtitle;
};
}); });
}); });

View File

@@ -0,0 +1,109 @@
const anims = {
imgs: {
// tree shape from https://thenounproject.com/icon/pine-tree-1471679/
tree: confetti.shapeFromPath({
path: 'M120 240c-41,14 -91,18 -120,1 29,-10 57,-22 81,-40 -18,2 -37,3 -55,-3 25,-14 48,-30 66,-51 -11,5 -26,8 -45,7 20,-14 40,-30 57,-49 -13,1 -26,2 -38,-1 18,-11 35,-25 51,-43 -13,3 -24,5 -35,6 21,-19 40,-41 53,-67 14,26 32,48 54,67 -11,-1 -23,-3 -35,-6 15,18 32,32 51,43 -13,3 -26,2 -38,1 17,19 36,35 56,49 -19,1 -33,-2 -45,-7 19,21 42,37 67,51 -19,6 -37,5 -56,3 25,18 53,30 82,40 -30,17 -79,13 -120,-1l0 41 -31 0 0 -41z',
matrix: [0.03597122302158273, 0, 0, 0.03597122302158273, -4.856115107913669, -5.071942446043165]
}),
// pumpkin shape from https://thenounproject.com/icon/pumpkin-5253388/
pumpkin: confetti.shapeFromPath({
path: 'M449.4 142c-5 0-10 .3-15 1a183 183 0 0 0-66.9-19.1V87.5a17.5 17.5 0 1 0-35 0v36.4a183 183 0 0 0-67 19c-4.9-.6-9.9-1-14.8-1C170.3 142 105 219.6 105 315s65.3 173 145.7 173c5 0 10-.3 14.8-1a184.7 184.7 0 0 0 169 0c4.9.7 9.9 1 14.9 1 80.3 0 145.6-77.6 145.6-173s-65.3-173-145.7-173zm-220 138 27.4-40.4a11.6 11.6 0 0 1 16.4-2.7l54.7 40.3a11.3 11.3 0 0 1-7 20.3H239a11.3 11.3 0 0 1-9.6-17.5zM444 383.8l-43.7 17.5a17.7 17.7 0 0 1-13 0l-37.3-15-37.2 15a17.8 17.8 0 0 1-13 0L256 383.8a17.5 17.5 0 0 1 13-32.6l37.3 15 37.2-15c4.2-1.6 8.8-1.6 13 0l37.3 15 37.2-15a17.5 17.5 0 0 1 13 32.6zm17-86.3h-82a11.3 11.3 0 0 1-6.9-20.4l54.7-40.3a11.6 11.6 0 0 1 16.4 2.8l27.4 40.4a11.3 11.3 0 0 1-9.6 17.5z',
matrix: [0.020491803278688523, 0, 0, 0.020491803278688523, -7.172131147540983, -5.9016393442622945]
}),
// heart shape from https://thenounproject.com/icon/heart-1545381/
heart: confetti.shapeFromPath({
path: 'M167 72c19,-38 37,-56 75,-56 42,0 76,33 76,75 0,76 -76,151 -151,227 -76,-76 -151,-151 -151,-227 0,-42 33,-75 75,-75 38,0 57,18 76,56z',
matrix: [0.03333333333333333, 0, 0, 0.03333333333333333, -5.566666666666666, -5.533333333333333]
})
},
// Function to animate the snow effect
snow: async function () {
var duration = 15 * 1000;
var animationEnd = Date.now() + duration;
var skew = 1;
function randomInRange(min, max) {
return Math.random() * (max - min) + min;
}
// Function to animate the first snow particle
async function snowAnimation() {
return new Promise(resolve => {
var firstSnowDuration = 3000; // 3 seconds
var firstAnimationEnd = Date.now() + firstSnowDuration;
(function firstSnowFrame() {
var timeLeft = firstAnimationEnd - Date.now();
confetti({
particleCount: 1,
startVelocity: 0,
ticks: Math.max(200, 500 * (timeLeft / firstSnowDuration)),
origin: {
x: Math.random(),
y: randomInRange(0.2, 0.4) * skew - 0.4
},
colors: ['#ffffff'],
shapes: ['circle'],
gravity: randomInRange(0.4, 0.6),
scalar: randomInRange(0.4, 1),
drift: randomInRange(-0.4, 0.4)
});
if (timeLeft > 0) {
requestAnimationFrame(firstSnowFrame);
} else {
resolve(); // Resolve the promise when the animation is complete
}
})();
});
}
// Function to animate the continuous snow particles
function mainSnowAnimation() {
(function frame() {
var timeLeft = animationEnd - Date.now();
var ticks = Math.max(200, 500 * (timeLeft / duration));
skew = Math.max(0.8, skew - 0.001);
confetti({
particleCount: 1,
startVelocity: 0,
ticks: ticks,
origin: {
x: Math.random(),
y: randomInRange(0.2, 0.4) * skew - 0.4
},
colors: ['#ffffff'],
shapes: ['circle'],
gravity: randomInRange(0.4, 0.6),
scalar: randomInRange(0.4, 1),
drift: randomInRange(-0.4, 0.4)
});
confetti({
particleCount: 1,
startVelocity: 0,
ticks: ticks,
origin: {
x: Math.random(),
y: randomInRange(0.2, 0.4) * skew - 0.2
},
colors: ['#ffffff'],
shapes: ['circle'],
gravity: randomInRange(0.4, 0.6),
scalar: randomInRange(0.4, 1),
drift: randomInRange(-0.4, 0.4)
});
if (timeLeft > 0) {
requestAnimationFrame(frame);
}
})();
}
// Start the animations
await snowAnimation(); // Wait for the first snow animation to complete
mainSnowAnimation(); // Start the continuous snow animation
}
};