Compare commits
46 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
b32fcbd8f1 | ||
|
ab6fe350d3 | ||
|
d9c4a4a428 | ||
|
77dcea14cc | ||
|
dcbbfde4a5 | ||
|
685c2dc210 | ||
|
3d67f6d5ae | ||
|
e4ca9882b9 | ||
|
9c4a886799 | ||
|
6839090631 | ||
|
1232eea297 | ||
|
b88509d210 | ||
|
2f95583af1 | ||
|
6f6ab38c9f | ||
|
10887b8358 | ||
|
4ce43bdac3 | ||
|
9947bfc3dc | ||
|
b68a85ac2f | ||
|
dfbb0492ac | ||
|
def3cd312d | ||
|
5539ae2c63 | ||
|
53bf782ddc | ||
|
01e9154a33 | ||
|
04ec30066d | ||
|
8a1b2ac65f | ||
|
316f6685e0 | ||
|
d26d82121c | ||
|
cb0cfbe640 | ||
|
31731d5f24 | ||
|
6fece01fa9 | ||
|
afedc14db5 | ||
|
9d337fa315 | ||
|
f04efda911 | ||
|
3a0941c67c | ||
|
08fbe470ad | ||
|
a82d5d67e2 | ||
|
52883d335d | ||
|
93ab0b9731 | ||
|
3e86319bb8 | ||
|
baa6e33fcd | ||
|
cdb9130a3d | ||
|
d7ad3a8812 | ||
|
c28640217b | ||
|
9e2bef0037 | ||
33eb3a931f | |||
6753ed7e79 |
103
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -1,65 +1,64 @@
|
||||
name: Bug
|
||||
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"
|
||||
labels: bug
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
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.**_
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Device information
|
||||
description:
|
||||
value: |
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
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.**_
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Device information
|
||||
description: "_Please provide the following information:_"
|
||||
value: |
|
||||
- OS:
|
||||
- Hardware Specs:
|
||||
- Etc:
|
||||
validations:
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the issue
|
||||
description: _Please attach videos or screenshots if possible_
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the issue
|
||||
description: _Please attach videos or screenshots if possible_
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: _Please attach videos or screenshots if possible_
|
||||
value: |
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: _Please attach videos or screenshots if possible_
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
validations:
|
||||
required: true
|
||||
2.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Crash log
|
||||
description: _If the app crashes, **please provide the crash log**.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Crash log
|
||||
description: _If the app crashes, **please provide the crash log**.
|
||||
render: shell
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
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._
|
||||
multiple: false
|
||||
options:
|
||||
- ✅ Yes, I'm using the latest version of bsky-desktop
|
||||
- ❌ No, I'll explain with additional information below
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
attributes:
|
||||
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._
|
||||
multiple: false
|
||||
options:
|
||||
- ✅ Yes, I'm using the latest version of bsky-desktop
|
||||
- ❌ No, I'll explain with additional information below
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
validations:
|
||||
required: false
|
||||
|
@@ -2,9 +2,9 @@ name: Build and Release bsky-desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ $default-branch ]
|
||||
branches: [$default-branch]
|
||||
pull_request:
|
||||
branches: [ $default-branch ]
|
||||
branches: [$default-branch]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -15,8 +15,6 @@ jobs:
|
||||
build-linux:
|
||||
name: Build bsky-desktop (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
artifact: ${{ steps.upload-artifact.outputs.artifact }}
|
||||
env:
|
||||
ext: "AppImage"
|
||||
GITHUB_TOKEN: ${{ secrets.GHT }}
|
||||
@@ -33,12 +31,30 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build (armv7l)
|
||||
run: npm run build -- --arch armv7l
|
||||
|
||||
- name: Build (x64)
|
||||
run: npm run build -- --arch x64
|
||||
|
||||
- name: Build (arm64)
|
||||
run: npm run build -- --arch arm64
|
||||
|
||||
- name: Download appimagelint and its deps
|
||||
run: |
|
||||
sudo apt update && sudo apt install fuse -y
|
||||
wget https://github.com/TheAssassin/appimagelint/releases/download/continuous/appimagelint-x86_64.AppImage
|
||||
chmod +x appimagelint-x86_64.AppImage
|
||||
|
||||
- name: Check the appimage(s)
|
||||
run: ./appimagelint-x86_64.AppImage dist/*.AppImage
|
||||
|
||||
- name: Generate checksum
|
||||
run: |
|
||||
sha256sum dist/*.AppImage > dist/sha256sum.txt
|
||||
sha256sum dist/*.deb >> dist/sha256sum.txt
|
||||
sha256sum dist/*.zip >> dist/sha256sum.txt
|
||||
|
||||
- name: Upload Linux Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
id: upload-artifact
|
||||
@@ -46,15 +62,15 @@ jobs:
|
||||
name: linux-artifacts
|
||||
path: |
|
||||
dist/*.AppImage
|
||||
dist/*.deb
|
||||
dist/*.zip
|
||||
dist/latest*.yml
|
||||
dist/sha256sum.txt
|
||||
|
||||
build-windows:
|
||||
name: Build bsky-desktop (Windows)
|
||||
runs-on: windows-latest
|
||||
outputs:
|
||||
artifact: ${{ steps.upload-artifact.outputs.artifact }}
|
||||
env:
|
||||
ext: "exe"
|
||||
GITHUB_TOKEN: ${{ secrets.GHT }}
|
||||
|
||||
steps:
|
||||
@@ -68,12 +84,22 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build (ia32)
|
||||
run: npm run build -- --arch ia32
|
||||
|
||||
- name: Build (x64)
|
||||
run: npm run build -- --arch x64
|
||||
|
||||
|
||||
- name: Build (arm64)
|
||||
run: npm run build -- --arch arm64
|
||||
|
||||
- name: Generate checksum
|
||||
run: |
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -82,15 +108,16 @@ jobs:
|
||||
name: windows-artifacts
|
||||
path: |
|
||||
dist/*.exe
|
||||
dist/*.msi
|
||||
dist/*.appx
|
||||
dist/*.zip
|
||||
dist/latest*.yml
|
||||
dist/sha256sum.txt
|
||||
|
||||
build-macos:
|
||||
name: Build bsky-desktop (macOS)
|
||||
runs-on: macos-latest
|
||||
outputs:
|
||||
artifact: ${{ steps.upload-artifact.outputs.artifact }}
|
||||
env:
|
||||
ext: "dmg"
|
||||
GITHUB_TOKEN: ${{ secrets.GHT }}
|
||||
|
||||
steps:
|
||||
@@ -110,6 +137,12 @@ jobs:
|
||||
|
||||
- name: Build (arm64)
|
||||
run: npm run build -- --arch arm64
|
||||
|
||||
- name: Generate checksum
|
||||
run: |
|
||||
shasum -a 256 dist/*.dmg > dist/sha256sum.txt
|
||||
shasum -a 256 dist/*.pkg >> dist/sha256sum.txt
|
||||
shasum -a 256 dist/*.zip >> dist/sha256sum.txt
|
||||
|
||||
- name: Upload macOS Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -118,23 +151,28 @@ jobs:
|
||||
name: macos-artifacts
|
||||
path: |
|
||||
dist/*.dmg
|
||||
dist/*.pkg
|
||||
dist/*.zip
|
||||
dist/latest*.yml
|
||||
dist/sha256sum.txt
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, build-windows, build-macos]
|
||||
outputs:
|
||||
version_tag: ${{ steps.version.outputs.version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GHT }}
|
||||
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Get app version
|
||||
id: version
|
||||
uses: pchynoweth/action-get-npm-version@1.1.1
|
||||
|
||||
|
||||
- name: Download Linux Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -152,9 +190,13 @@ jobs:
|
||||
with:
|
||||
name: macos-artifacts
|
||||
path: dist/macos
|
||||
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R dist
|
||||
|
||||
- name: Combine checksums
|
||||
run: |
|
||||
cat dist/linux/sha256sum.txt dist/windows/sha256sum.txt dist/macos/sha256sum.txt > sha256sums.txt
|
||||
|
||||
- name: Upload Release
|
||||
id: create_release
|
||||
@@ -165,5 +207,57 @@ jobs:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
dist/linux/*.AppImage
|
||||
dist/linux/*.deb
|
||||
dist/linux/*.zip
|
||||
dist/windows/*.exe
|
||||
dist/macos/*.dmg
|
||||
dist/windows/*.msi
|
||||
dist/windows/*.appx
|
||||
dist/windows/*.zip
|
||||
dist/macos/*.dmg
|
||||
dist/macos/*.pkg
|
||||
dist/macos/*.zip
|
||||
sha256sums.txt
|
||||
|
||||
aur:
|
||||
name: Publish to AUR
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
env:
|
||||
AUR_TOKEN: ${{ secrets.AUR_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Download linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-artifacts
|
||||
path: dist/linux
|
||||
|
||||
- name: List downloaded files
|
||||
run: ls -R dist
|
||||
|
||||
- name: Show content of sha256sum.txt
|
||||
run: cat dist/linux/sha256sum.txt
|
||||
|
||||
- name: Get app version
|
||||
id: version
|
||||
uses: pchynoweth/action-get-npm-version@1.1.1
|
||||
|
||||
- name: Extract checksum from sha256sum.txt and change build version
|
||||
run: |
|
||||
new_checksum=$(awk 'NR==1 { print $1 }' ./dist/linux/sha256sum.txt)
|
||||
sed -i "s|sha256sums=('SKIP' 'SKIP')|sha256sums=('$new_checksum' 'SKIP')|" ./build/arch-pkg/PKGBUILD
|
||||
sed -i "s/^pkgver=.*$/pkgver=${{ steps.version.outputs.version }}/" ./build/arch-pkg/PKGBUILD
|
||||
|
||||
- name: Publish AUR package
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v3.0.1
|
||||
with:
|
||||
pkgname: bskydesktop
|
||||
pkgbuild: ./build/arch-pkg/PKGBUILD
|
||||
commit_username: ${{ secrets.AUR_USERNAME }}
|
||||
commit_email: ${{ secrets.AUR_EMAIL }}
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_message: New Version
|
||||
ssh_keyscan_types: rsa,ecdsa,ed25519
|
57
README.md
@@ -1,3 +1,58 @@
|
||||
# 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:
|
||||
[](https://github.com/oxmc/bsky-desktop/actions/workflows/build-and-release.yml)
|
||||
|
||||
[](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.
|
116
build-app.js
@@ -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);
|
||||
}
|
||||
})();
|
13
build/arch-pkg/.SRCINFO
Normal file
@@ -0,0 +1,13 @@
|
||||
pkgbase = bskydesktop
|
||||
pkgdesc = Bluesky Desktop - A decentralized social networking client distributed as an AppImage
|
||||
pkgver = 1.0.8
|
||||
pkgrel = 1
|
||||
url = https://github.com/oxmc/bsky-desktop
|
||||
arch = x86_64
|
||||
arch = aarch64
|
||||
license = MIT
|
||||
makedepends = curl
|
||||
depends = fuse2
|
||||
options = !strip
|
||||
|
||||
pkgname = bskydesktop
|
82
build/arch-pkg/PKGBUILD
Normal file
@@ -0,0 +1,82 @@
|
||||
# Maintainer: GizzyUwU me@gizzy.pro
|
||||
# Maintainer: oxmc contact@oxmc.is-a.dev
|
||||
|
||||
pkgname=bskydesktop
|
||||
pkgver=""
|
||||
pkgrel=1
|
||||
pkgdesc="Bluesky Desktop - A decentralized social networking client distributed as an AppImage"
|
||||
arch=('x86_64' 'aarch64')
|
||||
url="https://github.com/oxmc/bsky-desktop"
|
||||
license=('AGPL-3.0-only')
|
||||
depends=('fuse2')
|
||||
makedepends=('curl')
|
||||
options=('!strip')
|
||||
icon_url="https://raw.githubusercontent.com/oxmc/bsky-desktop/refs/heads/main/src/ui/images/logo.png"
|
||||
icon_name="bsky-desktop.png"
|
||||
|
||||
prepare() {
|
||||
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"
|
||||
|
||||
case "$CARCH" in
|
||||
x86_64)
|
||||
appimage_name="bskyDesktop-${latest_tag:1}-linux-x64.AppImage"
|
||||
sha256sum=$(echo "$latest_sha256" | grep "x64" | cut -d' ' -f1)
|
||||
;;
|
||||
aarch64)
|
||||
appimage_name="bskyDesktop-${latest_tag:1}-linux-arm64.AppImage"
|
||||
sha256sum=$(echo "$latest_sha256" | grep "arm64" | cut -d' ' -f1)
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $CARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
source=(
|
||||
"https://github.com/oxmc/bsky-desktop/releases/download/$latest_tag/$appimage_name"
|
||||
"$icon_url"
|
||||
)
|
||||
echo "AppImage source: ${source[0]}"
|
||||
sha256sums=("$sha256sum" 'SKIP')
|
||||
curl -L "${source[0]}" -o "$srcdir/bskyDesktop.appimage"
|
||||
curl -L "${source[1]}" -o "$srcdir/$icon_name"
|
||||
}
|
||||
|
||||
package() {
|
||||
appimage_dest="$pkgdir/opt/appimages/bsky-desktop"
|
||||
bin_dest="$pkgdir/usr/bin/bsky-desktop"
|
||||
desktop_file="$pkgdir/usr/share/applications/bsky-desktop.desktop"
|
||||
icon_dest="$pkgdir/usr/share/icons/hicolor/128x128/apps/$icon_name"
|
||||
|
||||
if [[ ! -f "$srcdir/bskyDesktop.appimage" ]]; then
|
||||
echo "Error: AppImage file not found: $srcdir/bskyDesktop.appimage"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$srcdir/$icon_name" ]]; then
|
||||
echo "Error: Icon file not found: $srcdir/$icon_name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -d "$pkgdir/opt/appimages"
|
||||
install -Dm755 "$srcdir/bskyDesktop.appimage" "$appimage_dest"
|
||||
|
||||
install -d "$(dirname "$bin_dest")"
|
||||
ln -sf "/opt/appimages/bsky-desktop" "$bin_dest"
|
||||
|
||||
install -d "$(dirname "$icon_dest")"
|
||||
install -Dm644 "$srcdir/$icon_name" "$icon_dest"
|
||||
|
||||
install -d "$(dirname "$desktop_file")"
|
||||
install -Dm644 /dev/stdin "$desktop_file" <<EOF
|
||||
[Desktop Entry]
|
||||
Name=Bluesky Desktop
|
||||
Comment=Bluesky Desktop Client
|
||||
Exec=/usr/bin/bsky-desktop %u
|
||||
Icon=bsky-desktop
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Network;Social;Application;
|
||||
EOF
|
||||
}
|
129
build/build-app.js
Normal 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
@@ -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
44
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky-desktop",
|
||||
"version": "1.0.7",
|
||||
"version": "1.1.3",
|
||||
"description": "A desktop app of bsky.app",
|
||||
"main": "src/app/main.js",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"pack": "electron-builder --dir",
|
||||
"dist": "electron-builder",
|
||||
"rebuild": "electron-rebuild",
|
||||
"build": "node ./build-app.js"
|
||||
"build": "node ./build/build-app.js"
|
||||
},
|
||||
"author": {
|
||||
"name": "oxmc",
|
||||
@@ -24,43 +24,13 @@
|
||||
"@electron/remote": "^2.1.2",
|
||||
"adm-zip": "^0.5.12",
|
||||
"axios": "^1.6.8",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"less": "^4.2.1",
|
||||
"log4js": "^6.9.1",
|
||||
"node-notifier": "^10.0.0",
|
||||
"semver": "^7.6.3",
|
||||
"v8-compile-cache": "^2.3.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.oxmc.bskyDesktop",
|
||||
"productName": "bskyDesktop",
|
||||
"asarUnpack": [
|
||||
"./node_modules/node-notifier/**/*"
|
||||
],
|
||||
"artifactName": "bsky-desktop.${ext}",
|
||||
"mac": {
|
||||
"icon": "src/ui/images/logo.icns",
|
||||
"category": "Network"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"appimage"
|
||||
],
|
||||
"icon": "src/ui/images/logo.png",
|
||||
"category": "Network"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "src/ui/images/logo.ico"
|
||||
},
|
||||
"protocols": [
|
||||
{
|
||||
"name": "bsky",
|
||||
"schemes": [
|
||||
"bsky"
|
||||
]
|
||||
}
|
||||
]
|
||||
"stylus": "^0.64.0",
|
||||
"usercss-meta": "^0.12.0",
|
||||
"v8-compile-cache": "^2.3.0",
|
||||
"winreg": "^1.2.5"
|
||||
}
|
||||
}
|
||||
|
17
src/app/contributors.json
Normal 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"
|
||||
}
|
||||
]
|
21
src/app/discord-rpc/LICENSE
Normal 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.
|
660
src/app/discord-rpc/client.js
Normal 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;
|
178
src/app/discord-rpc/constants.js
Normal 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,
|
||||
};
|
10
src/app/discord-rpc/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const util = require('./util');
|
||||
|
||||
module.exports = {
|
||||
Client: require('./client'),
|
||||
register(id) {
|
||||
return util.register(`discord-${id}`);
|
||||
},
|
||||
};
|
6
src/app/discord-rpc/transports/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
ipc: require('./ipc'),
|
||||
websocket: require('./websocket'),
|
||||
};
|
173
src/app/discord-rpc/transports/ipc.js
Normal 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;
|
77
src/app/discord-rpc/transports/websocket.js
Normal 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;
|
50
src/app/discord-rpc/util.js
Normal 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,
|
||||
};
|
341
src/app/main.js
@@ -1,20 +1,25 @@
|
||||
const { app, BrowserWindow, BrowserView, globalShortcut, ipcMain, ipcRenderer, 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 asar = require('@electron/asar');
|
||||
const windowStateKeeper = require("electron-window-state");
|
||||
const windowStateKeeper = require("./window-state/index");
|
||||
const { setupTitlebar, attachTitlebarToWindow } = require("./titlebar/main");
|
||||
const openAboutWindow = require("./about-window/src/index").default;
|
||||
const badge = require('./badge');
|
||||
const nodeNotifier = require('node-notifier');
|
||||
const contextMenu = require('./context-menu');
|
||||
const asarUpdater = require('./utils/asarUpdater');
|
||||
//const loadCRX = require('./utils/loadCRX');
|
||||
const autoUpdater = require('./utils/auto-update');
|
||||
const loadCRX = require('./utils/loadCRX');
|
||||
const userStyles = require('./utils/userStyles');
|
||||
const log4js = require("log4js");
|
||||
//const axios = require("axios");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
require('v8-compile-cache');
|
||||
|
||||
// Load package.json and contributors.json
|
||||
const packageJson = require(path.join(__dirname, '..', '..', 'package.json'));
|
||||
const contributors = require(path.join(__dirname, 'contributors.json'));
|
||||
|
||||
// isUpdaing:
|
||||
global.isUpdating = false;
|
||||
@@ -35,7 +40,10 @@ global.paths = {
|
||||
home: os.homedir(),
|
||||
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.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:
|
||||
global.urls = {
|
||||
@@ -44,11 +52,41 @@ global.urls = {
|
||||
|
||||
// Settings urls:
|
||||
global.settings = {
|
||||
general: `${global.urls.main}/settings`,
|
||||
account: `${global.urls.main}/settings/account`,
|
||||
appearance: `${global.urls.main}/settings/appearance`,
|
||||
privacy: `${global.urls.main}/settings/privacy-and-security`,
|
||||
general: `${global.urls.main}/settings`
|
||||
};
|
||||
global.settings.account = `${global.settings.general}/account`;
|
||||
global.settings.appearance = `${global.settings.general}/appearance`;
|
||||
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:
|
||||
const badgeOptions = {
|
||||
@@ -99,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`));
|
||||
};
|
||||
};
|
||||
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:
|
||||
if (!fs.existsSync(global.paths.data)) {
|
||||
@@ -113,6 +151,24 @@ if (!fs.existsSync(global.paths.temp)) {
|
||||
fs.mkdirSync(global.paths.temp, { recursive: true });
|
||||
};
|
||||
|
||||
// Create user directory if it does not exist:
|
||||
if (!fs.existsSync(global.paths.data, 'user')) {
|
||||
logger.info("Creating User Directory");
|
||||
fs.mkdirSync(global.paths.user, { recursive: true });
|
||||
};
|
||||
|
||||
// Create extensions directory if it does not exist:
|
||||
if (!fs.existsSync(global.paths.extensions)) {
|
||||
logger.info("Creating Extensions Directory");
|
||||
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");
|
||||
@@ -133,16 +189,16 @@ setupTitlebar();
|
||||
// Disable reload and F5 if not in dev mode:
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
app.on('browser-window-focus', function () {
|
||||
globalShortcut.register("CommandOrControl+R", () => {
|
||||
/*globalShortcut.register("CommandOrControl+R", () => {
|
||||
//console.log("CommandOrControl+R is pressed: Shortcut Disabled");
|
||||
});
|
||||
globalShortcut.register("F5", () => {
|
||||
//console.log("F5 is pressed: Shortcut Disabled");
|
||||
});
|
||||
});*/
|
||||
});
|
||||
app.on('browser-window-blur', function () {
|
||||
globalShortcut.unregister('CommandOrControl+R');
|
||||
globalShortcut.unregister('F5');
|
||||
/*globalShortcut.unregister('CommandOrControl+R');
|
||||
globalShortcut.unregister('F5');*/
|
||||
});
|
||||
};
|
||||
|
||||
@@ -205,14 +261,14 @@ function createWindow() {
|
||||
PageView.setBounds({
|
||||
x: 0,
|
||||
y: 30,
|
||||
width: mainWindow.getBounds().width,
|
||||
width: mainWindow.isMaximized() ? mainWindow.getBounds().width - 16 : mainWindow.getBounds().width,
|
||||
height: mainWindow.getBounds().height - 30,
|
||||
});
|
||||
mainWindow.on("resize", () => {
|
||||
PageView.setBounds({
|
||||
x: 0,
|
||||
y: 30,
|
||||
width: mainWindow.getBounds().width,
|
||||
width: mainWindow.isMaximized() ? mainWindow.getBounds().width - 16 : mainWindow.getBounds().width,
|
||||
height: mainWindow.getBounds().height - 30,
|
||||
});
|
||||
});
|
||||
@@ -267,7 +323,7 @@ function createWindow() {
|
||||
};
|
||||
});
|
||||
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' };
|
||||
});
|
||||
// Log PageView navigations:
|
||||
@@ -287,6 +343,7 @@ function showAboutWindow() {
|
||||
//open_devtools: process.env.NODE_ENV !== 'production',
|
||||
use_version_info: [
|
||||
['Application Version', `${global.appInfo.version}`],
|
||||
['Contributors', contributors.map((contributor) => contributor.name).join(', ')],
|
||||
],
|
||||
license: `MIT, GPL-2.0, GPL-3.0, ${global.appInfo.license}`,
|
||||
});
|
||||
@@ -294,7 +351,7 @@ function showAboutWindow() {
|
||||
|
||||
function createTray() {
|
||||
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.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: global.appInfo.name, enabled: false },
|
||||
@@ -370,7 +427,7 @@ function handleDeeplink(commandLine) {
|
||||
break;
|
||||
|
||||
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;
|
||||
|
||||
default:
|
||||
@@ -481,74 +538,198 @@ app.whenReady().then(() => {
|
||||
// Set session:
|
||||
global.session = ses;
|
||||
|
||||
// Initialize the updater:
|
||||
logger.log("Initializing Updater");
|
||||
asarUpdater.init();
|
||||
|
||||
// updater events:
|
||||
asarUpdater.on('available', (task) => {
|
||||
//console.log('Update availible for', task)
|
||||
logger.log("Update availible for", task.name);
|
||||
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...' });
|
||||
global.isUpdating = true;
|
||||
});
|
||||
asarUpdater.on('not-available', (task) => {
|
||||
//console.log('not-available', task);
|
||||
logger.log("No Updates Available for", task);
|
||||
});
|
||||
asarUpdater.on('progress', (task, p) => {
|
||||
console.log(task.name, p);
|
||||
if (global.splash) global.splash.webContents.send('ui:progtext', { title: 'Downloading Update', subtitle: 'Downloading update...' });
|
||||
if (global.splash) global.splash.webContents.send('ui:progbar', { reason: 'update', prog: p });
|
||||
});
|
||||
asarUpdater.on('downloaded', (task) => {
|
||||
//console.log('downloaded', task);
|
||||
logger.log("Downloaded Update for,", task.name);
|
||||
global.PageView.webContents.send('ui:notif', JSON.stringify({ title: 'Update Downloaded', message: 'Restarting to apply update...' }));
|
||||
if (global.splash) global.splash.webContents.send('ui:progtext', { title: 'Update Downloaded', subtitle: 'Restarting to apply update...' });
|
||||
});
|
||||
asarUpdater.on('completed', (manifest, tasks) => {
|
||||
console.log('completed', manifest, tasks);
|
||||
if (tasks.length === 0) {
|
||||
setTimeout(() => {
|
||||
logger.log("Quitting and Installing Update");
|
||||
asarUpdater.quitAndInstall();
|
||||
}, 5000);
|
||||
};
|
||||
//app.quit()
|
||||
});
|
||||
asarUpdater.on('error', (err) => {
|
||||
//console.error(err);
|
||||
logger.error(err);
|
||||
//app.quit()
|
||||
});
|
||||
|
||||
// Set the feed URL (only works in packaged app):
|
||||
if (app.isPackaged) {
|
||||
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');
|
||||
};
|
||||
|
||||
//Check for updates:
|
||||
logger.log("Checking for Updates");
|
||||
if (app.isPackaged) {
|
||||
const UPDATE_CHECK = 1000 * 60 * 60 * 4 // 4 hours
|
||||
setInterval(() => {
|
||||
//asarUpdater.checkForUpdates();
|
||||
}, UPDATE_CHECK);
|
||||
//asarUpdater.checkForUpdates();
|
||||
} else {
|
||||
logger.warn("Not checking for updates as app is not packaged");
|
||||
};
|
||||
|
||||
// Handle ipc for render:
|
||||
ipcMain.on('close-app', (event, arg) => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
ipcMain.on('app:restart', (event, arg) => {
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// Create windows and tray:
|
||||
createWindow();
|
||||
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 {
|
||||
logger.log("Failed to get singleInstanceLock, Quitting");
|
||||
app.quit();
|
||||
|
@@ -90,7 +90,7 @@ class Updater extends EventEmitter {
|
||||
};
|
||||
this.options = Object.assign({}, def, options);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ class Updater extends EventEmitter {
|
||||
|
||||
quitAndInstall(timeout) {
|
||||
setTimeout(() => {
|
||||
app.relaunch({ args: process.argv.slice(1) + ['--relaunch'] });
|
||||
app.relaunch({ args: process.argv.slice(1) + ['--relaunch', '--update-installed'] });
|
||||
app.exit(0);
|
||||
}, timeout || 100);
|
||||
}
|
||||
|
514
src/app/utils/auto-update.js
Normal file
@@ -0,0 +1,514 @@
|
||||
const childProcess = require('child_process');
|
||||
const { app } = require('electron');
|
||||
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
|
||||
const asarUpdater = require('./asarUpdater');
|
||||
|
||||
// Get system information
|
||||
const SystemInfo = require('./sysInfo');
|
||||
|
||||
// Get the current system platform
|
||||
const sys = new SystemInfo();
|
||||
|
||||
// Detect the app install type
|
||||
const installType = require('./installType');
|
||||
|
||||
// Setup the logger
|
||||
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() {
|
||||
asarUpdater.init();
|
||||
|
||||
// updater events:
|
||||
asarUpdater.on('available', (task) => {
|
||||
//console.log('Update availible for', task)
|
||||
logger.log("Update availible for", task.name);
|
||||
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...' });
|
||||
global.isUpdating = true;
|
||||
});
|
||||
asarUpdater.on('not-available', (task) => {
|
||||
//console.log('not-available', task);
|
||||
logger.log("No Updates Available for", task);
|
||||
});
|
||||
asarUpdater.on('progress', (task, p) => {
|
||||
console.log(task.name, p);
|
||||
if (global.splash) global.splash.webContents.send('ui:progtext', { title: 'Downloading Update', subtitle: 'Downloading update...' });
|
||||
if (global.splash) global.splash.webContents.send('ui:progbar', { reason: 'update', prog: p });
|
||||
});
|
||||
asarUpdater.on('downloaded', (task) => {
|
||||
//console.log('downloaded', task);
|
||||
logger.log("Downloaded Update for,", task.name);
|
||||
global.PageView.webContents.send('ui:notif', JSON.stringify({ title: 'Update Downloaded', message: 'Restarting to apply update...' }));
|
||||
if (global.splash) global.splash.webContents.send('ui:progtext', { title: 'Update Downloaded', subtitle: 'Restarting to apply update...' });
|
||||
});
|
||||
asarUpdater.on('completed', (manifest, tasks) => {
|
||||
console.log('completed', manifest, tasks);
|
||||
if (tasks.length === 0) {
|
||||
setTimeout(() => {
|
||||
logger.log("Quitting and Installing Update");
|
||||
asarUpdater.quitAndInstall();
|
||||
}, 5000);
|
||||
};
|
||||
//app.quit()
|
||||
});
|
||||
asarUpdater.on('error', (err) => {
|
||||
//console.error(err);
|
||||
logger.error(err);
|
||||
//app.quit()
|
||||
});
|
||||
|
||||
// Set the feed URL (only works in packaged app):
|
||||
if (app.isPackaged) {
|
||||
logger.log("Setting Feed URL for app.asar");
|
||||
asarUpdater.setFeedURL(path.join(global.paths.app_root), 'https://cdn.oxmc.me/apps/bsky-desktop/update/core');
|
||||
};
|
||||
|
||||
//Check for updates:
|
||||
logger.log("Checking for Updates");
|
||||
if (app.isPackaged) {
|
||||
const UPDATE_CHECK = 1000 * 60 * 60 * 4 // 4 hours
|
||||
setInterval(() => {
|
||||
asarUpdater.checkForUpdates();
|
||||
}, UPDATE_CHECK);
|
||||
asarUpdater.checkForUpdates();
|
||||
} else {
|
||||
logger.warn("Not checking for updates as app is not packaged");
|
||||
};
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
async function checkForUpdates() {
|
||||
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
|
||||
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 updating...');
|
||||
resolve({ err: 'old-os', msg: 'Windows 7 and 8 are not supported, please upgrade to Windows 10 or above, not updating...' });
|
||||
} else {
|
||||
// Check for updates, and if there are updates, download and install them
|
||||
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.' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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')) {
|
||||
// Check for updates, and if there are updates, download and install them
|
||||
logger.log('Checking for updates (mac)...');
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
121
src/app/utils/installType.js
Normal 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
|
||||
};
|
@@ -3,30 +3,91 @@ const path = require('path');
|
||||
const { session } = require('electron');
|
||||
const AdmZip = require('adm-zip');
|
||||
|
||||
/**
|
||||
* Converts a CRX file buffer to a ZIP buffer.
|
||||
* @param {Buffer} buf - The CRX file buffer.
|
||||
* @returns {Buffer} - The ZIP buffer extracted from the CRX file.
|
||||
*/
|
||||
function crxToZip(buf) {
|
||||
function calcLength(a, b, c, d) {
|
||||
let length = 0;
|
||||
length += a << 0;
|
||||
length += b << 8;
|
||||
length += c << 16;
|
||||
length += (d << 24) >>> 0;
|
||||
return length;
|
||||
}
|
||||
|
||||
// Check if the file is already a ZIP file
|
||||
if (buf[0] === 80 && buf[1] === 75 && buf[2] === 3 && buf[3] === 4) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Validate CRX magic number
|
||||
if (buf[0] !== 67 || buf[1] !== 114 || buf[2] !== 50 || buf[3] !== 52) {
|
||||
throw new Error('Invalid CRX file: Missing Cr24 magic number');
|
||||
}
|
||||
|
||||
const version = buf[4];
|
||||
const isV2 = version === 2;
|
||||
const isV3 = version === 3;
|
||||
|
||||
if ((!isV2 && !isV3) || buf[5] || buf[6] || buf[7]) {
|
||||
throw new Error('Unsupported CRX format version.');
|
||||
}
|
||||
|
||||
if (isV2) {
|
||||
const publicKeyLength = calcLength(buf[8], buf[9], buf[10], buf[11]);
|
||||
const signatureLength = calcLength(buf[12], buf[13], buf[14], buf[15]);
|
||||
const zipStartOffset = 16 + publicKeyLength + signatureLength;
|
||||
return buf.slice(zipStartOffset);
|
||||
}
|
||||
|
||||
const headerSize = calcLength(buf[8], buf[9], buf[10], buf[11]);
|
||||
const zipStartOffset = 12 + headerSize;
|
||||
return buf.slice(zipStartOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks a .crx file and loads it as an Electron extension.
|
||||
* @param {string} crxPath - Path to the .crx file.
|
||||
* @returns {Promise<string>} - Resolves with the extension ID after loading.
|
||||
*/
|
||||
async function loadCRX(crxPath) {
|
||||
const outputDir = path.join(__dirname, 'extensions', path.basename(crxPath, '.crx'));
|
||||
const outputDir = path.join(global.paths.extensions, path.basename(crxPath, '.crx'));
|
||||
|
||||
// Ensure the output directory exists
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// Extract the .crx file
|
||||
// Read the CRX file
|
||||
const crxData = fs.readFileSync(crxPath);
|
||||
const crxHeaderSize = crxData.readUInt32LE(8); // Extract header size from CRX
|
||||
const zipData = crxData.slice(crxHeaderSize);
|
||||
|
||||
// Save the ZIP content
|
||||
// Convert CRX to ZIP
|
||||
const zipData = crxToZip(crxData);
|
||||
|
||||
// Extract ZIP using AdmZip
|
||||
const zip = new AdmZip(zipData);
|
||||
zip.extractAllTo(outputDir, true);
|
||||
zip.getEntries().forEach((entry) => {
|
||||
const fullPath = path.join(outputDir, entry.entryName);
|
||||
|
||||
if (entry.isDirectory) {
|
||||
fs.mkdirSync(fullPath, { recursive: true });
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, entry.getData());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load the unpacked extension into Electron
|
||||
try {
|
||||
// Check for manifest.json
|
||||
const manifestPath = path.join(outputDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error('Extension is missing manifest.json');
|
||||
};
|
||||
// Load the extension
|
||||
const { id } = await session.defaultSession.loadExtension(outputDir);
|
||||
console.log(`Extension loaded with ID: ${id}`);
|
||||
return id;
|
||||
|
@@ -5,6 +5,7 @@ class SystemInfo {
|
||||
constructor() {
|
||||
this.platform = os.platform(); // 'win32', 'darwin', 'linux'
|
||||
this.release = os.release(); // OS version
|
||||
this.arch = os.arch(); // 'arm', 'arm64', 'x64', 'x86'
|
||||
this.versionInfo = this._getVersionInfo(); // Parsed version
|
||||
}
|
||||
|
||||
@@ -23,6 +24,26 @@ class SystemInfo {
|
||||
return this.platform === 'linux';
|
||||
}
|
||||
|
||||
// Check if current system architecture is ARM
|
||||
isARM() {
|
||||
return this.arch === 'arm';
|
||||
}
|
||||
|
||||
// Check if current system architecture is ARM64
|
||||
isARM64() {
|
||||
return this.arch === 'arm64';
|
||||
}
|
||||
|
||||
// Check if current system architecture is x64
|
||||
isX64() {
|
||||
return this.arch === 'x64';
|
||||
}
|
||||
|
||||
// Check if current system architecture is x86
|
||||
isX86() {
|
||||
return this.arch === 'x86';
|
||||
}
|
||||
|
||||
// Compare if current version is later than the given version
|
||||
laterThan(compareVersion) {
|
||||
const current = this.versionInfo;
|
||||
@@ -35,6 +56,21 @@ class SystemInfo {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare if current version is earlier than the given version
|
||||
earlierThan(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])
|
||||
_parseVersion(version) {
|
||||
return version.split('.').map((num) => parseInt(num, 10) || 0);
|
||||
@@ -57,13 +93,20 @@ class SystemInfo {
|
||||
return [0, 0, 0]; // Unknown system
|
||||
}
|
||||
}
|
||||
|
||||
// Get current version as a string (e.g., "10.15.7")
|
||||
getVersion() {
|
||||
return this.versionInfo.join('.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SystemInfo;
|
||||
|
||||
// Usage Example
|
||||
//const sys = new SystemInfo();
|
||||
//console.log(`Is Windows: ${sys.isWin()}`);
|
||||
//console.log(`Is macOS: ${sys.isMac()}`);
|
||||
//console.log(`Is Linux: ${sys.isLinux()}`);
|
||||
//console.log(`Current Version Info: ${sys.versionInfo.join('.')}`);
|
||||
//console.log(`Current Version Info: ${sys.getVersion()}`);
|
||||
//console.log(`Later than 10.0.19044: ${sys.laterThan('10.0.19044')}`);
|
||||
//console.log(`Later than 5.15.0 (Linux Kernel): ${sys.laterThan('5.15.0')}`);
|
260
src/app/utils/userStyles.js
Normal 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
|
||||
};
|
224
src/app/window-state/index.js
Normal 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
|
||||
};
|
||||
};
|
22
src/app/window-state/license
Normal 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
@@ -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);
|
||||
}
|
@@ -6,6 +6,7 @@
|
||||
-webkit-user-drag: none;
|
||||
/* Disable selection */
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html,
|
||||
|
BIN
src/ui/images/icons/128x128.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
src/ui/images/icons/16x16.png
Normal file
After Width: | Height: | Size: 538 B |
BIN
src/ui/images/icons/256x256.png
Normal file
After Width: | Height: | Size: 5.8 KiB |
BIN
src/ui/images/icons/32x32.png
Normal file
After Width: | Height: | Size: 939 B |
BIN
src/ui/images/icons/48x48.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
src/ui/images/icons/64x64.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
@@ -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">
|
||||
<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">
|
||||
<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>
|
||||
</circle>
|
||||
</svg>
|
||||
<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">
|
||||
<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>
|
||||
</circle>
|
||||
</svg>
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 812 B |
BIN
src/ui/images/mac.icns
Normal file
7
src/ui/images/spinner.svg
Normal 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 |
1
src/ui/lib/confetti-1.9.3-browser.min.js
vendored
Normal file
11
src/ui/lib/css/stylelint-bundle.min.js
vendored
Normal file
63
src/ui/lib/css/stylus-renderer.min.js
vendored
Normal file
31
src/ui/offline.html
Normal 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>
|
@@ -1,33 +1,37 @@
|
||||
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", {
|
||||
send: (channel, data) => {
|
||||
ipcRenderer.send(channel, data);
|
||||
if (allowedChannels.includes(channel)) {
|
||||
ipcRenderer.send(channel, data);
|
||||
}
|
||||
},
|
||||
on: (channel, callback) => {
|
||||
ipcRenderer.on(channel, (event, ...args) => callback(...args));
|
||||
if (allowedChannels.includes(channel)) {
|
||||
ipcRenderer.on(channel, (event, ...args) => callback(...args));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("badge", {
|
||||
update: (badgeNumber) => {
|
||||
ipcRenderer.send('ui:badgeCount', badgeNumber);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to dynamically load a script and append it to the DOM
|
||||
function loadScript(src, callback) {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => {
|
||||
console.log(`Loaded script: ${src}`);
|
||||
if (callback) callback();
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
console.error(`Failed to load script: ${src}`, error);
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
// Load a script asynchronously
|
||||
function loadScriptAsync(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.onload = () => {
|
||||
console.log(`Loaded script: ${src}`);
|
||||
resolve();
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
console.error(`Failed to load script: ${src}`, error);
|
||||
reject(error);
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// Inject CSS into the DOM
|
||||
@@ -39,18 +43,19 @@ function injectCSS(href) {
|
||||
console.log(`Injected CSS: ${href}`);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Inject CSS
|
||||
injectCSS('ui:///lib/izitoast.min.css');
|
||||
injectCSS('ui:///rend/extra-themes.css');
|
||||
injectCSS('ui:///css/fa/6.7.1/css/all.min.css');
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
injectCSS("ui:///lib/izitoast.min.css");
|
||||
injectCSS("ui:///rend/extra-themes.css");
|
||||
injectCSS("ui:///css/fa/6.7.1/css/all.min.css");
|
||||
|
||||
// Load jQuery first
|
||||
loadScript('ui:///lib/jquery-3.3.1.min.js', () => {
|
||||
// Load iziToast after jQuery
|
||||
loadScript('ui:///lib/izitoast.min.js', () => {
|
||||
loadScript('ui:///rend/register-handles.js');
|
||||
loadScript('ui:///rend/bsky-ext.js');
|
||||
});
|
||||
});
|
||||
});
|
||||
try {
|
||||
await loadScriptAsync("ui:///lib/jquery-3.3.1.min.js");
|
||||
await loadScriptAsync("ui:///lib/confetti-1.9.3-browser.min.js");
|
||||
await loadScriptAsync("ui:///lib/izitoast.min.js");
|
||||
await loadScriptAsync("ui:///rend/register-handles.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);
|
||||
}
|
||||
});
|
@@ -374,6 +374,15 @@ const BskyExt = {
|
||||
},
|
||||
"regex": /discord\.com\/invite\/([a-zA-Z0-9_]+)/
|
||||
},
|
||||
"discord_profile": {
|
||||
"name": "Discord",
|
||||
"type": "social",
|
||||
"icon": "fab fa-discord",
|
||||
"brand": {
|
||||
"color": "#7289DA",
|
||||
},
|
||||
"regex": /discord\.com\/users\/([a-zA-Z0-9_]+)/
|
||||
},
|
||||
"discord.gg": {
|
||||
"name": "Discord",
|
||||
"type": "messaging",
|
||||
@@ -473,6 +482,33 @@ const BskyExt = {
|
||||
},
|
||||
"regex": /irc:\/\/([a-zA-Z0-9_]+)/
|
||||
},
|
||||
"itchio": {
|
||||
"name": "Itch.io",
|
||||
"type": "content",
|
||||
"icon": "fab fa-itch-io",
|
||||
"brand": {
|
||||
"color": "#FA5C5C",
|
||||
},
|
||||
"regex": /([a-zA-Z0-9_]+)\.itch\.io/
|
||||
},
|
||||
"etsy_shop": {
|
||||
"name": "Etsy",
|
||||
"type": "content",
|
||||
"icon": "fab fa-etsy",
|
||||
"brand": {
|
||||
"color": "#D5641C",
|
||||
},
|
||||
"regex": /etsy\.com\/shop\/([a-zA-Z0-9_]+)/
|
||||
},
|
||||
"etsy_user": {
|
||||
"name": "Etsy",
|
||||
"type": "content",
|
||||
"icon": "fab fa-etsy",
|
||||
"brand": {
|
||||
"color": "#D5641C",
|
||||
},
|
||||
"regex": /([a-zA-Z0-9_]+)\.etsy\.com/
|
||||
},
|
||||
"email": {
|
||||
"name": "Email",
|
||||
"type": "messaging",
|
||||
@@ -498,7 +534,43 @@ const BskyExt = {
|
||||
"brand": {
|
||||
"color": "#1DA1F2",
|
||||
},
|
||||
"regex": /twitter\.com\/([a-zA-Z0-9_]+)/
|
||||
"regex": /(twitter\.com|x\.com)\/([a-zA-Z0-9_]+)\/?$/
|
||||
},
|
||||
"xbox": {
|
||||
"name": "Xbox",
|
||||
"type": "social",
|
||||
"icon": "fab fa-xbox",
|
||||
"brand": {
|
||||
"color": "#107C10",
|
||||
},
|
||||
"regex": /xbox\.com\/([a-zA-Z0-9_]+)/
|
||||
},
|
||||
"playstation": {
|
||||
"name": "PlayStation",
|
||||
"type": "social",
|
||||
"icon": "fab fa-playstation",
|
||||
"brand": {
|
||||
"color": "#003087",
|
||||
},
|
||||
"regex": /playstation\.com\/([a-zA-Z0-9_]+)/
|
||||
},
|
||||
"steam": {
|
||||
"name": "Steam",
|
||||
"type": "content",
|
||||
"icon": "fab fa-steam",
|
||||
"brand": {
|
||||
"color": "#000000",
|
||||
},
|
||||
"regex": /steamcommunity\.com\/id\/([a-zA-Z0-9_]+)/
|
||||
},
|
||||
"tiktok": {
|
||||
"name": "TikTok",
|
||||
"type": "social",
|
||||
"icon": "fab fa-tiktok",
|
||||
"brand": {
|
||||
"color": "#000000",
|
||||
},
|
||||
"regex": /tiktok\.com\/@([a-zA-Z0-9_]+)/
|
||||
},
|
||||
"instagram": {
|
||||
"name": "Instagram",
|
||||
@@ -729,6 +801,72 @@ const BskyExt = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @constant {Object} linkStylesOverrides - Style overrides for specific link types
|
||||
*/
|
||||
linkStylesOverrides: {
|
||||
"bluesky": {
|
||||
"default": {
|
||||
"color": "rgb(0, 133, 255)",
|
||||
"background": "#0000",
|
||||
"border": "#0000",
|
||||
},
|
||||
"hover": {
|
||||
"color": "#fff",
|
||||
"background": "#0000",
|
||||
"border": "#0000"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"default": {
|
||||
"color": "#FFFFFF",
|
||||
"background": "#7289DA",
|
||||
"border": "#7289DA",
|
||||
},
|
||||
"hover": {
|
||||
"color": "#7289DA",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#7289DA"
|
||||
}
|
||||
},
|
||||
"linktree": {
|
||||
"default": {
|
||||
"color": "#FFFFFF",
|
||||
"background": "#39e09b",
|
||||
"border": "#39e09b",
|
||||
},
|
||||
"hover": {
|
||||
"color": "#39e09b",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#39e09b"
|
||||
}
|
||||
},
|
||||
"carrd": {
|
||||
"default": {
|
||||
"color": "#FFFFFF",
|
||||
"background": "#2C2F33",
|
||||
"border": "#2C2F33",
|
||||
},
|
||||
"hover": {
|
||||
"color": "#2C2F33",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#2C2F33"
|
||||
}
|
||||
},
|
||||
"dribbble": {
|
||||
"default": {
|
||||
"color": "#FFFFFF",
|
||||
"background": "#EA4C89",
|
||||
"border": "#EA4C89",
|
||||
},
|
||||
"hover": {
|
||||
"color": "#EA4C89",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#EA4C89"
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* @constant {Object} buttonStyles - The button styles for the supported messaging apps
|
||||
*
|
||||
@@ -1109,7 +1247,7 @@ const BskyExt = {
|
||||
let header = bio.closest("[data-testid='profileView']");
|
||||
|
||||
// Get the profile header button group element
|
||||
let buttonGroup = header.querySelector('[role="button"]').parentElement;
|
||||
let buttonGroup = header.querySelector("div.css-175oi2r.r-2llsf > div:nth-child(1) > div > div:nth-child(2) > div.css-175oi2r.r-12vffkv");
|
||||
|
||||
// Check if the profile header button group element exists
|
||||
if (!buttonGroup) {
|
||||
|
@@ -20,7 +20,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
message: event.message,
|
||||
position: 'topRight',
|
||||
timeout: 5000,
|
||||
...event.options,
|
||||
...event.options.izitoast,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to display notification:', error);
|
||||
@@ -36,6 +36,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
ipcRenderer.on('ui:progtext', (event, other) => {
|
||||
console.log('Updating progress text:', other);
|
||||
infotext.innerText = other.title;
|
||||
progtext.innerText = other.subtitle;
|
||||
if (other.subtitle) {
|
||||
progtext.innerText = other.subtitle;
|
||||
};
|
||||
});
|
||||
});
|
109
src/ui/rend/specialAnimations.js
Normal 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
|
||||
}
|
||||
};
|