From d9c4a4a4281c7f9ea5bdf3944fe983e8579c7786 Mon Sep 17 00:00:00 2001 From: oxmc <67136658+oxmc@users.noreply.github.com> Date: Sat, 28 Dec 2024 01:08:07 -0800 Subject: [PATCH] Improve app and add more install options (and fix old .dmg files) --- .github/workflows/build-and-release.yml | 31 +- README.md | 14 + build-app.js | 116 ---- build/build-app.js | 104 +++ build/build-config.json | 55 ++ package.json | 65 +- src/app/contributors.json | 17 + src/app/discord-rpc/LICENSE | 21 + src/app/discord-rpc/client.js | 660 ++++++++++++++++++++ src/app/discord-rpc/constants.js | 178 ++++++ src/app/discord-rpc/index.js | 10 + src/app/discord-rpc/transports/index.js | 6 + src/app/discord-rpc/transports/ipc.js | 173 +++++ src/app/discord-rpc/transports/websocket.js | 77 +++ src/app/discord-rpc/util.js | 50 ++ src/app/main.js | 20 +- src/app/utils/auto-update.js | 2 +- src/ui/images/loading.svg | 12 +- src/ui/images/mac.icns | Bin 0 -> 17929 bytes src/ui/images/spinner.svg | 7 + 20 files changed, 1423 insertions(+), 195 deletions(-) delete mode 100644 build-app.js create mode 100644 build/build-app.js create mode 100644 build/build-config.json create mode 100644 src/app/contributors.json create mode 100644 src/app/discord-rpc/LICENSE create mode 100644 src/app/discord-rpc/client.js create mode 100644 src/app/discord-rpc/constants.js create mode 100644 src/app/discord-rpc/index.js create mode 100644 src/app/discord-rpc/transports/index.js create mode 100644 src/app/discord-rpc/transports/ipc.js create mode 100644 src/app/discord-rpc/transports/websocket.js create mode 100644 src/app/discord-rpc/util.js create mode 100644 src/ui/images/mac.icns create mode 100644 src/ui/images/spinner.svg diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index d79c399..7c9637c 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -31,6 +31,9 @@ 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 @@ -49,6 +52,8 @@ jobs: - 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 @@ -57,6 +62,8 @@ jobs: name: linux-artifacts path: | dist/*.AppImage + dist/*.deb + dist/*.zip dist/latest*.yml dist/sha256sum.txt @@ -64,7 +71,6 @@ jobs: name: Build bsky-desktop (Windows) runs-on: windows-latest env: - ext: "exe" GITHUB_TOKEN: ${{ secrets.GHT }} steps: @@ -78,16 +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 @@ -96,6 +108,9 @@ jobs: name: windows-artifacts path: | dist/*.exe + dist/*.msi + dist/*.appx + dist/*.zip dist/latest*.yml dist/sha256sum.txt @@ -103,7 +118,6 @@ jobs: name: Build bsky-desktop (macOS) runs-on: macos-latest env: - ext: "dmg" GITHUB_TOKEN: ${{ secrets.GHT }} steps: @@ -127,6 +141,8 @@ jobs: - 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 @@ -135,6 +151,8 @@ jobs: name: macos-artifacts path: | dist/*.dmg + dist/*.pkg + dist/*.zip dist/latest*.yml dist/sha256sum.txt @@ -189,8 +207,15 @@ jobs: generate_release_notes: true files: | dist/linux/*.AppImage + dist/linux/*.deb + dist/linux/*.zip dist/windows/*.exe + dist/windows/*.msi + dist/windows/*.appx + dist/windows/*.zip dist/macos/*.dmg + dist/macos/*.pkg + dist/macos/*.zip sha256sums.txt aur: diff --git a/README.md b/README.md index 3d2e189..41f91f4 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,20 @@ Bsky Desktop is an Electron-based application for Bsky that allows users to mana [![Packaging status](https://repology.org/badge/vertical-allrepos/bskydesktop.svg?columns=4&exclude_unsupported=1)](https://repology.org/project/bskydesktop/versions) +#### Windows install options: +- Zip (x64, arm64, ia32) +- Setup (exe, msi, appx) (x64, arm64, ia32) + +#### Mac install options: +- Zip (x64, arm64) +- Dmg (x64, arm64) +- Pkg (x64, arm64) + +#### Linux install options: +- Zip (x64, arm64, ia32) +- AppImage (x64, arm64, ia32) +- Deb (x64, arm64, ia32) + ### Build Instructions for Bsky Desktop To build and run Bsky Desktop locally, follow these steps: diff --git a/build-app.js b/build-app.js deleted file mode 100644 index 87248a0..0000000 --- a/build-app.js +++ /dev/null @@ -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); - } -})(); \ No newline at end of file diff --git a/build/build-app.js b/build/build-app.js new file mode 100644 index 0000000..35f2521 --- /dev/null +++ b/build/build-app.js @@ -0,0 +1,104 @@ +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 + +// 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'); +} + +// Read build-config.json +const buildConfigPath = path.join(__dirname, 'build-config.json'); +let buildConfig; +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); +}); \ No newline at end of file diff --git a/build/build-config.json b/build/build-config.json new file mode 100644 index 0000000..ad6c894 --- /dev/null +++ b/build/build-config.json @@ -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" + ] + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index a7fc33a..b49ed20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky-desktop", - "version": "1.1.0", + "version": "1.1.1", "description": "A desktop app of bsky.app", "main": "src/app/main.js", "scripts": { @@ -8,26 +8,12 @@ "pack": "electron-builder --dir", "dist": "electron-builder", "rebuild": "electron-rebuild", - "build": "node ./build-app.js" + "build": "node ./build/build-app.js" }, "author": { "name": "oxmc", "email": "oxmc7769.mail@gmail.com" }, - "contributors": [ - { - "name": "oxmc", - "email": "contact@oxmc.is-a.dev" - }, - { - "name": "PlOszukiwaczDEV", - "email": "ploszukiwacz1@duck.com" - }, - { - "name": "GizzyUwU", - "email": "me@gizzy.pro" - } - ], "license": "AGPL-3.0-only", "devDependencies": { "electron": "^29.4.6", @@ -45,52 +31,5 @@ "semver": "^7.6.3", "usercss-meta": "^0.12.0", "v8-compile-cache": "^2.3.0" - }, - "build": { - "appId": "com.oxmc.bskyDesktop", - "productName": "bskyDesktop", - "asarUnpack": [ - "./node_modules/node-notifier/**/*" - ], - "artifactName": "bsky-desktop.${ext}", - "mac": { - "target": [ - "dmg", - "pkg" - ], - "icon": "build/icons/mac-icon.icns", - "category": "Network" - }, - "pkg": { - "scripts": "build/mac-pkg/scripts", - "installLocation": "/Applications", - "allowAnywhere": true, - "allowCurrentUserHome": true, - "allowRootDirectory": true, - "isVersionChecked": true, - "isRelocatable": false, - "overwriteAction": "upgrade" - }, - "linux": { - "target": [ - "appimage" - ], - "icon": "src/ui/images/icons", - "category": "Network" - }, - "win": { - "target": [ - "nsis" - ], - "icon": "src/ui/images/logo.ico" - }, - "protocols": [ - { - "name": "bsky", - "schemes": [ - "bsky" - ] - } - ] } } diff --git a/src/app/contributors.json b/src/app/contributors.json new file mode 100644 index 0000000..0eed47e --- /dev/null +++ b/src/app/contributors.json @@ -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" + } +] \ No newline at end of file diff --git a/src/app/discord-rpc/LICENSE b/src/app/discord-rpc/LICENSE new file mode 100644 index 0000000..fedc9bc --- /dev/null +++ b/src/app/discord-rpc/LICENSE @@ -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. diff --git a/src/app/discord-rpc/client.js b/src/app/discord-rpc/client.js new file mode 100644 index 0000000..0ff29da --- /dev/null +++ b/src/app/discord-rpc/client.js @@ -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} + */ + 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} + */ + getGuild(id, timeout) { + return this.request(RPCCommands.GET_GUILD, { guild_id: id, timeout }); + } + + /** + * Fetch all guilds + * @param {number} [timeout] Timeout request + * @returns {Promise>} + */ + getGuilds(timeout) { + return this.request(RPCCommands.GET_GUILDS, { timeout }); + } + + /** + * Get a channel + * @param {Snowflake} id Channel ID + * @param {number} [timeout] Timeout request + * @returns {Promise} + */ + 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>} + */ + 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} + */ + 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} + */ + 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; diff --git a/src/app/discord-rpc/constants.js b/src/app/discord-rpc/constants.js new file mode 100644 index 0000000..441f832 --- /dev/null +++ b/src/app/discord-rpc/constants.js @@ -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, +}; diff --git a/src/app/discord-rpc/index.js b/src/app/discord-rpc/index.js new file mode 100644 index 0000000..a3444b3 --- /dev/null +++ b/src/app/discord-rpc/index.js @@ -0,0 +1,10 @@ +'use strict'; + +const util = require('./util'); + +module.exports = { + Client: require('./client'), + register(id) { + return util.register(`discord-${id}`); + }, +}; diff --git a/src/app/discord-rpc/transports/index.js b/src/app/discord-rpc/transports/index.js new file mode 100644 index 0000000..42db529 --- /dev/null +++ b/src/app/discord-rpc/transports/index.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = { + ipc: require('./ipc'), + websocket: require('./websocket'), +}; diff --git a/src/app/discord-rpc/transports/ipc.js b/src/app/discord-rpc/transports/ipc.js new file mode 100644 index 0000000..f050a01 --- /dev/null +++ b/src/app/discord-rpc/transports/ipc.js @@ -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; diff --git a/src/app/discord-rpc/transports/websocket.js b/src/app/discord-rpc/transports/websocket.js new file mode 100644 index 0000000..bd5a671 --- /dev/null +++ b/src/app/discord-rpc/transports/websocket.js @@ -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; diff --git a/src/app/discord-rpc/util.js b/src/app/discord-rpc/util.js new file mode 100644 index 0000000..2fdc937 --- /dev/null +++ b/src/app/discord-rpc/util.js @@ -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, +}; diff --git a/src/app/main.js b/src/app/main.js index 6c78c05..e7af383 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, BrowserView, globalShortcut, ipcMain, Tray, Menu, protocol, session } = require("electron"); +const { app, BrowserWindow, BrowserView, globalShortcut, ipcMain, Tray, Menu, protocol, session, dialog } = require("electron"); const electronremote = require("@electron/remote/main"); //const asar = require('@electron/asar'); const windowStateKeeper = require("electron-window-state"); @@ -16,6 +16,14 @@ const os = require("os"); require('v8-compile-cache'); const packageJson = require(path.join(__dirname, '..', '..', 'package.json')); +const contributors = require(path.join(__dirname, 'contributors.json')); + +// Check if app is ran from the installer dmg (macOS) +if (process.platform === 'darwin' && app.isPackaged && app.isInApplicationsFolder()) { + // Creeate a dialog to ask the user to move the app to the Applications folder + dialog.showErrorBox('Move to Applications folder', 'Please move the app to the Applications folder to ensure it works correctly.'); + app.quit(); +}; // isUpdaing: global.isUpdating = false; @@ -102,7 +110,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)) { @@ -302,7 +310,7 @@ function showAboutWindow() { //open_devtools: process.env.NODE_ENV !== 'production', use_version_info: [ ['Application Version', `${global.appInfo.version}`], - ['Contributors', packageJson.contributors.map((contributor) => contributor.name).join(', ')], + ['Contributors', contributors.map((contributor) => contributor.name).join(', ')], ], license: `MIT, GPL-2.0, GPL-3.0, ${global.appInfo.license}`, }); @@ -310,7 +318,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 }, @@ -550,13 +558,13 @@ app.whenReady().then(() => { const cssContent = fs.readFileSync(cssFile, 'utf-8'); const result = await userStyles.parseCSS(cssContent); - logger.info(`Loaded userstyle: ${result.metadata.name}`); + 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 && compiled.sites['bsky.app']) { + if (compiled.sites?.['bsky.app']) { // Apply the userstyle to the PageView await PageView.webContents.insertCSS(compiled.sites['bsky.app']); diff --git a/src/app/utils/auto-update.js b/src/app/utils/auto-update.js index 3fe56ed..d2aa486 100644 --- a/src/app/utils/auto-update.js +++ b/src/app/utils/auto-update.js @@ -80,7 +80,7 @@ function asarUpdate() { function checkForUpdates() { // Current system information - logger.log('Current system information:', sys.platform, sys.getVersion()); + logger.log('Current system information:', sys.platform, sys.getVersion(), sys.arch); // Check if the current system is Windows if (sys.isWin()) { diff --git a/src/ui/images/loading.svg b/src/ui/images/loading.svg index 05737f0..04c124f 100644 --- a/src/ui/images/loading.svg +++ b/src/ui/images/loading.svg @@ -1,7 +1,7 @@ - - - - - - \ No newline at end of file + + + + + + \ No newline at end of file diff --git a/src/ui/images/mac.icns b/src/ui/images/mac.icns new file mode 100644 index 0000000000000000000000000000000000000000..56cc561b924b340d47afacc1db81a0b0cbb047ca GIT binary patch literal 17929 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 literal 0 HcmV?d00001 diff --git a/src/ui/images/spinner.svg b/src/ui/images/spinner.svg new file mode 100644 index 0000000..48c4918 --- /dev/null +++ b/src/ui/images/spinner.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file