mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 20:52:12 +09:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7601bf98ee | ||
|
|
c7ec2a0f58 | ||
|
|
6a5e0056a8 | ||
|
|
78d105b46e | ||
|
|
c68d62b8a0 | ||
|
|
df58020f4c | ||
|
|
f052ce05aa | ||
|
|
eedfd9275f | ||
|
|
416af4bec4 | ||
|
|
108d282ddd | ||
|
|
a6103244b0 | ||
|
|
38935c83c5 |
@@ -7,7 +7,7 @@ ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
ARG NODE_MAJOR=26
|
||||
ARG ELP_VERSION=2026-08-10
|
||||
ARG PNPM_VERSION=12.4.2
|
||||
ARG PNPM_VERSION=11.27.0
|
||||
ARG WASM_BINDGEN_VERSION=0.2.128
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -38,7 +38,11 @@
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"erlang.includePaths": ["."],
|
||||
"search.exclude": {
|
||||
"**/_build/default/lib/fluxer_gateway": true
|
||||
}
|
||||
},
|
||||
"extensions": [
|
||||
"biomejs.biome",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/.direnv/
|
||||
/.fluxer/
|
||||
/.pnpm-store/
|
||||
/.vscode/
|
||||
|
||||
**/*.css.d.ts
|
||||
**/*.tsbuildinfo
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://fluxer.app/donate">
|
||||
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
|
||||
<a href="https://fluxer.app/download">
|
||||
<img src="https://img.shields.io/badge/Download-fluxer.app-4641D9" alt="Download" /></a>
|
||||
<a href="https://docs.fluxer.app">
|
||||
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a>
|
||||
<a href="https://fluxer.app/donate">
|
||||
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
|
||||
<a href="./LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
|
||||
</p>
|
||||
@@ -19,5 +21,128 @@
|
||||
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||
|
||||
<p align="center">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||
</p>
|
||||
|
||||
## Download
|
||||
|
||||
| Windows | macOS | Linux | Android | iOS |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flatpak][flatpak-ref] | [APK][android-apk] | [TestFlight][ios-testflight] |
|
||||
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
|
||||
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
|
||||
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
|
||||
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
|
||||
| | | [AppImage (x64)][linux-appimage-x64] | | |
|
||||
| | | [AppImage (ARM64)][linux-appimage-arm64] | | |
|
||||
| | | [tar.gz (x64)][linux-targz-x64] | | |
|
||||
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
|
||||
|
||||
The macOS disk image is universal and runs on both Apple silicon and Intel. Windows and Linux need the build that matches your processor.
|
||||
|
||||
On Linux, prefer a package repository over a file. Fluxer then updates with the rest of your system.
|
||||
|
||||
## Linux package repositories
|
||||
|
||||
All four repositories serve stable and canary. The package is `fluxer` for stable and `fluxer-canary` for canary.
|
||||
|
||||
### Flatpak
|
||||
|
||||
Opening [this reference file][flatpak-ref] hands the install to your desktop software manager. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` pasted into the address bar.
|
||||
|
||||
From a terminal:
|
||||
|
||||
```sh
|
||||
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
```
|
||||
|
||||
### Debian and Ubuntu
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
|
||||
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.com/deb/fluxer.sources
|
||||
sudo apt update && sudo apt install fluxer
|
||||
```
|
||||
|
||||
### Fedora and RHEL
|
||||
|
||||
```sh
|
||||
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/fluxer.repo
|
||||
sudo dnf install fluxer
|
||||
```
|
||||
|
||||
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because the base repositories do not ship `libXScrnSaver`. Fedora does not need this.
|
||||
|
||||
### Arch Linux
|
||||
|
||||
The repository is signed, so pacman needs the key in its own keyring once:
|
||||
|
||||
```sh
|
||||
sudo pacman-key --init
|
||||
curl -fsSL -o /tmp/fluxer-archive-keyring.asc https://pkgs.fluxer.com/keys/fluxer-archive-keyring.asc
|
||||
sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
|
||||
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
|
||||
```
|
||||
|
||||
`--lsign-key` is the step that makes pacman trust the key. Then add the repository:
|
||||
|
||||
```sh
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
|
||||
|
||||
[fluxer]
|
||||
SigLevel = Required TrustedOnly
|
||||
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
|
||||
REPO
|
||||
sudo pacman -Syu fluxer
|
||||
```
|
||||
|
||||
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, which is why the heredoc above is quoted.
|
||||
|
||||
Full setup notes, including the canary channel, live in the [Linux repositories documentation][docs-linux].
|
||||
|
||||
## Other ways to run it
|
||||
|
||||
- [Open Fluxer in a browser](https://web.fluxer.app) with no install at all.
|
||||
- [Host your own instance][docs-selfhost] from this repository.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Documentation home][docs]
|
||||
- [Downloads][docs-downloads]
|
||||
- [Self-hosting][docs-selfhost]
|
||||
|
||||
## License
|
||||
|
||||
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
|
||||
|
||||
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
|
||||
imagery are copyright Fluxer and all rights reserved, as set out in
|
||||
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its
|
||||
own terms, listed in
|
||||
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Public availability of this repository does not grant trademark, brand, or
|
||||
endorsement rights.
|
||||
|
||||
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
|
||||
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
|
||||
[win-portable-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/portable
|
||||
[win-portable-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/portable
|
||||
[mac-dmg]: https://pkgs.fluxer.com/desktop/stable/darwin/arm64/latest/dmg
|
||||
[linux-deb-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/deb
|
||||
[linux-deb-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/deb
|
||||
[linux-rpm-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/rpm
|
||||
[linux-rpm-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/rpm
|
||||
[linux-appimage-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/appimage
|
||||
[linux-appimage-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/appimage
|
||||
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
|
||||
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
|
||||
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
|
||||
[obtainium]: https://obtainium.imranr.dev/
|
||||
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
|
||||
[docs]: https://docs.fluxer.app
|
||||
[docs-downloads]: https://docs.fluxer.app/downloads/overview/
|
||||
[docs-linux]: https://docs.fluxer.app/downloads/linux-repositories/
|
||||
[docs-selfhost]: https://docs.fluxer.app/operator/get-started/
|
||||
|
||||
@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
|
||||
&& npm install -g pnpm@12.4.2 \
|
||||
&& npm install -g pnpm@11.27.0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
|
||||
|
||||
@@ -5,7 +5,7 @@ FROM node:26-trixie-slim AS base
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN npm install -g pnpm@12.4.2
|
||||
RUN npm install -g pnpm@11.27.0
|
||||
|
||||
FROM base AS deploy
|
||||
|
||||
@@ -57,7 +57,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libvips42t64 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g pnpm@12.4.2
|
||||
RUN npm install -g pnpm@11.27.0
|
||||
|
||||
COPY --from=deploy /out ./
|
||||
COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist
|
||||
|
||||
@@ -94,5 +94,5 @@
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@12.4.2"
|
||||
"packageManager": "pnpm@11.27.0"
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ function serializeGroupDMChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
name: channel.name ?? undefined,
|
||||
name: channel.name ?? null,
|
||||
icon: channel.iconHash ?? null,
|
||||
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
|
||||
nicks: nicknameMap.size > 0 ? nicks : undefined,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearch
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {serializeChannelForAudit} from '@app/api/utils/AuditSerializationUtils';
|
||||
import {applyProtectedOverwriteBits} from '@app/api/utils/featureUtils';
|
||||
import {overwriteGrantedBits} from '@app/api/utils/PermissionUtils';
|
||||
import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
|
||||
import type {VoiceRegionAvailability} from '@app/api/voice/VoiceModel';
|
||||
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
|
||||
@@ -208,25 +209,6 @@ export class ChannelOperationsService {
|
||||
userId,
|
||||
channelId: channel.id,
|
||||
});
|
||||
if (!isOwner) {
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
const allowPerms = (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS;
|
||||
if ((allowPerms & ~channelPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
const nextDeny = new Map<RoleID | UserID, bigint>();
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
|
||||
nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS);
|
||||
}
|
||||
for (const [targetId, existing] of previousPermissionOverwrites ?? []) {
|
||||
const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n);
|
||||
if ((removedDeny & ~channelPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
}
|
||||
permissionOverwrites = new Map();
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
|
||||
@@ -251,6 +233,18 @@ export class ChannelOperationsService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!isOwner) {
|
||||
const targetIds = new Set([...(previousPermissionOverwrites?.keys() ?? []), ...permissionOverwrites.keys()]);
|
||||
for (const targetId of targetIds) {
|
||||
const grantedBits = overwriteGrantedBits(
|
||||
previousPermissionOverwrites?.get(targetId),
|
||||
permissionOverwrites.get(targetId),
|
||||
);
|
||||
if ((grantedBits & ~channelPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const requestedParentId =
|
||||
data.parent_id !== undefined ? (data.parent_id ? createChannelID(data.parent_id) : null) : channel.parentId;
|
||||
@@ -646,9 +640,8 @@ export class ChannelOperationsService {
|
||||
const sanitizedAllow = protectedBits.allow;
|
||||
const sanitizedDeny = protectedBits.deny;
|
||||
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
||||
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
|
||||
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const grantedBits = overwriteGrantedBits(existing, {allow: sanitizedAllow, deny: sanitizedDeny});
|
||||
if (!hasAdministrator && (grantedBits & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const previousPermissionOverwrites = channel.permissionOverwrites;
|
||||
const nextOverwrite = new ChannelPermissionOverwrite({
|
||||
type: params.overwrite.type,
|
||||
|
||||
@@ -294,6 +294,48 @@ describe('Channel Permission Overwrites', () => {
|
||||
expect(overwrite?.allow).toBe(Permissions.VIEW_CHANNEL.toString());
|
||||
expect(overwrite?.deny).toBe(Permissions.MANAGE_MESSAGES.toString());
|
||||
});
|
||||
test('should let an editor change an overwrite that already allows a permission they lack', async () => {
|
||||
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const manager = members[0];
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Queue Manager',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
const botRole = await createRole(harness, owner.token, guild.id, {name: 'Bot'});
|
||||
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
|
||||
await createPermissionOverwrite(harness, owner.token, systemChannel.id, botRole.id, {
|
||||
type: 0,
|
||||
allow: Permissions.PIN_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
await createBuilder(harness, manager.token)
|
||||
.put(`/channels/${systemChannel.id}/permissions/${botRole.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: (Permissions.PIN_MESSAGES | Permissions.SEND_MESSAGES).toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const updated = await getChannel(harness, owner.token, systemChannel.id);
|
||||
const botOverwrite = updated.permission_overwrites?.find((o) => o.id === botRole.id);
|
||||
expect(botOverwrite?.allow).toBe((Permissions.PIN_MESSAGES | Permissions.SEND_MESSAGES).toString());
|
||||
});
|
||||
test('should reject an editor granting a permission they lack', async () => {
|
||||
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const manager = members[0];
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Queue Manager',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
const botRole = await createRole(harness, owner.token, guild.id, {name: 'Bot'});
|
||||
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
|
||||
await createBuilder(harness, manager.token)
|
||||
.put(`/channels/${systemChannel.id}/permissions/${botRole.id}`)
|
||||
.body({type: 0, allow: Permissions.PIN_MESSAGES.toString(), deny: '0'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('should propagate category permission patches only to children that were synced when the category changed', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const targetRole = await createRole(harness, owner.token, guild.id, {name: 'Readers'});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createFriendship, createGroupDmChannel, getChannel} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('Group DM name clear', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['null', null],
|
||||
])('sends a null name to every recipient when cleared with %s', async (_label, clearedName) => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
await createBuilder<ChannelResponse>(harness, user1.token)
|
||||
.patch(`/channels/${groupDm.id}`)
|
||||
.body({name: 'Weekend plans'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
|
||||
try {
|
||||
const cleared = await createBuilder<ChannelResponse>(harness, user1.token)
|
||||
.patch(`/channels/${groupDm.id}`)
|
||||
.body({name: clearedName})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(cleared).toHaveProperty('name', null);
|
||||
const channelUpdates = dispatchSpy.mock.calls.filter(([params]) => params.event === 'CHANNEL_UPDATE');
|
||||
expect(channelUpdates.map(([params]) => params.userId.toString()).sort()).toEqual(
|
||||
[user1.userId, user2.userId, user3.userId].sort(),
|
||||
);
|
||||
for (const [params] of channelUpdates) {
|
||||
expect(params.data).toHaveProperty('name', null);
|
||||
}
|
||||
} finally {
|
||||
dispatchSpy.mockRestore();
|
||||
}
|
||||
expect(await getChannel(harness, user2.token, groupDm.id)).toHaveProperty('name', null);
|
||||
});
|
||||
});
|
||||
@@ -24224,7 +24224,7 @@
|
||||
"description": "The ID of the guild this channel belongs to",
|
||||
"$ref": "#/components/schemas/SnowflakeStringType"
|
||||
},
|
||||
"name": {"description": "The name of the channel", "type": "string"},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]},
|
||||
"topic": {"description": "The topic of the channel", "type": ["string", "null"]},
|
||||
"url": {
|
||||
"description": "The URL associated with the channel",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {overwriteGrantedBits} from '@app/api/utils/PermissionUtils';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const BOT_OVERWRITE = {
|
||||
allow: Permissions.ADD_REACTIONS | Permissions.SEND_MESSAGES | Permissions.MANAGE_MESSAGES | Permissions.PIN_MESSAGES,
|
||||
deny: 0n,
|
||||
};
|
||||
|
||||
describe('overwriteGrantedBits', () => {
|
||||
it('grants nothing when an overwrite is resubmitted unmodified', () => {
|
||||
expect(overwriteGrantedBits(BOT_OVERWRITE, {...BOT_OVERWRITE})).toBe(0n);
|
||||
});
|
||||
|
||||
it('ignores allow bits that were already set when another bit is flipped', () => {
|
||||
const before = {allow: Permissions.PIN_MESSAGES, deny: 0n};
|
||||
const after = {allow: Permissions.PIN_MESSAGES | Permissions.SEND_MESSAGES, deny: 0n};
|
||||
expect(overwriteGrantedBits(before, after)).toBe(Permissions.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
it('grants nothing when an allow bit is withdrawn', () => {
|
||||
const before = {allow: Permissions.SEND_MESSAGES | Permissions.MANAGE_MESSAGES, deny: 0n};
|
||||
const after = {allow: Permissions.SEND_MESSAGES, deny: 0n};
|
||||
expect(overwriteGrantedBits(before, after)).toBe(0n);
|
||||
});
|
||||
|
||||
it('grants nothing when a deny is added for a permission the editor lacks', () => {
|
||||
const after = {allow: Permissions.VIEW_CHANNEL, deny: Permissions.MANAGE_MESSAGES};
|
||||
expect(overwriteGrantedBits(null, after)).toBe(Permissions.VIEW_CHANNEL);
|
||||
});
|
||||
|
||||
it('grants the bits lifted out of deny', () => {
|
||||
const before = {allow: 0n, deny: Permissions.ADD_REACTIONS | Permissions.SEND_MESSAGES};
|
||||
const after = {allow: 0n, deny: Permissions.ADD_REACTIONS};
|
||||
expect(overwriteGrantedBits(before, after)).toBe(Permissions.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
it('treats a removed overwrite as granting everything it denied', () => {
|
||||
const before = {allow: Permissions.SEND_MESSAGES, deny: Permissions.ADD_REACTIONS};
|
||||
expect(overwriteGrantedBits(before, undefined)).toBe(Permissions.ADD_REACTIONS);
|
||||
});
|
||||
});
|
||||
@@ -51,3 +51,14 @@ export async function hasPermission(
|
||||
): Promise<boolean> {
|
||||
return await gatewayService.checkPermission(params);
|
||||
}
|
||||
|
||||
export function overwriteGrantedBits(
|
||||
before: {allow: bigint; deny: bigint} | null | undefined,
|
||||
after: {allow: bigint; deny: bigint} | null | undefined,
|
||||
): bigint {
|
||||
const beforeAllow = before?.allow ?? 0n;
|
||||
const beforeDeny = before?.deny ?? 0n;
|
||||
const afterAllow = after?.allow ?? 0n;
|
||||
const afterDeny = after?.deny ?? 0n;
|
||||
return (afterAllow & ~beforeAllow) | (beforeDeny & ~afterDeny);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {canProvisionPremiumFromSubscriptionStatus} from '@app/api/stripe/StripeSubscriptionAccessPolicy';
|
||||
import {
|
||||
getInvoiceLatestLinePeriodEnd,
|
||||
getPrimarySubscriptionItem,
|
||||
getSubscriptionPremiumPeriodEnd,
|
||||
getSubscriptionStartDate,
|
||||
@@ -142,6 +143,39 @@ function trackMostRecentTerminalSubscription(
|
||||
return current;
|
||||
}
|
||||
|
||||
const PAID_INVOICE_LOOKUP_LIMIT = 10;
|
||||
|
||||
async function getPaidThroughFromSubscriptionInvoices(
|
||||
stripe: Stripe,
|
||||
subscription: Stripe.Subscription | null,
|
||||
): Promise<Date | null> {
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
let invoices: Stripe.ApiList<Stripe.Invoice>;
|
||||
try {
|
||||
invoices = await stripe.invoices.list({
|
||||
subscription: subscription.id,
|
||||
status: 'paid',
|
||||
limit: PAID_INVOICE_LOOKUP_LIMIT,
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{subscriptionId: subscription.id, error},
|
||||
'Failed to list paid invoices while reconciling premium state; not stripping on incomplete data',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
let paidThrough: Date | null = null;
|
||||
for (const invoice of invoices.data) {
|
||||
const periodEnd = getInvoiceLatestLinePeriodEnd(invoice);
|
||||
if (periodEnd && (paidThrough === null || periodEnd.getTime() > paidThrough.getTime())) {
|
||||
paidThrough = periodEnd;
|
||||
}
|
||||
}
|
||||
return paidThrough;
|
||||
}
|
||||
|
||||
async function getEffectiveActiveStripeSubscription(
|
||||
stripe: Stripe,
|
||||
user: User,
|
||||
@@ -240,17 +274,28 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri
|
||||
if (hasStalePremium && !hasNonStripePremium) {
|
||||
const patch: Partial<UserRow> = {};
|
||||
let effectivePremiumUntil = getEffectivePremiumUntil(user);
|
||||
const paidThrough = await getPaidThroughFromSubscriptionInvoices(stripe, mostRecentTerminalSubscription);
|
||||
if (mostRecentTerminalSubscription?.ended_at && user.premiumUntil) {
|
||||
const subscriptionEndedAt = new Date(mostRecentTerminalSubscription.ended_at * 1000);
|
||||
if (subscriptionEndedAt.getTime() < user.premiumUntil.getTime()) {
|
||||
patch.premium_until = subscriptionEndedAt;
|
||||
patch.premium_grace_ends_at = subscriptionEndedAt;
|
||||
const entitlementEnd =
|
||||
paidThrough && paidThrough.getTime() > subscriptionEndedAt.getTime() ? paidThrough : subscriptionEndedAt;
|
||||
if (entitlementEnd.getTime() < user.premiumUntil.getTime()) {
|
||||
patch.premium_until = entitlementEnd;
|
||||
patch.premium_grace_ends_at = entitlementEnd;
|
||||
effectivePremiumUntil = getEffectivePremiumUntil({
|
||||
premiumUntil: subscriptionEndedAt,
|
||||
premiumUntil: entitlementEnd,
|
||||
premiumGiftExtensionEndsAt: user.premiumGiftExtensionEndsAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (paidThrough && (effectivePremiumUntil == null || paidThrough.getTime() > effectivePremiumUntil.getTime())) {
|
||||
patch.premium_until = paidThrough;
|
||||
patch.premium_grace_ends_at = paidThrough;
|
||||
effectivePremiumUntil = getEffectivePremiumUntil({
|
||||
premiumUntil: paidThrough,
|
||||
premiumGiftExtensionEndsAt: user.premiumGiftExtensionEndsAt,
|
||||
});
|
||||
}
|
||||
const hasFutureLocalEntitlement = effectivePremiumUntil != null && Date.now() <= effectivePremiumUntil.getTime();
|
||||
if (hasFutureLocalEntitlement) {
|
||||
if (user.premiumWillCancel !== true) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes';
|
||||
import {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
|
||||
import {User} from '@app/api/models/User';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
@@ -13,6 +16,77 @@ import {afterEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const USER_ID = createUserID(834271905123471361n);
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const ONE_DAY_MS = 24 * ONE_HOUR_MS;
|
||||
|
||||
function createPremiumUser(overrides: Partial<UserRow> = {}): User {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: USER_ID,
|
||||
username: 'paidlate',
|
||||
discriminator: 1,
|
||||
premium_type: 1,
|
||||
premium_since: new Date(Date.now() - 200 * ONE_DAY_MS),
|
||||
premium_until: new Date(Date.now() + 20 * ONE_DAY_MS),
|
||||
stripe_customer_id: 'cus_test',
|
||||
stripe_subscription_id: 'sub_test',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createCancelledSubscription(endedAtMs: number): Stripe.Subscription {
|
||||
return {
|
||||
id: 'sub_test',
|
||||
status: 'canceled',
|
||||
customer: 'cus_test',
|
||||
ended_at: Math.floor(endedAtMs / 1000),
|
||||
canceled_at: Math.floor(endedAtMs / 1000),
|
||||
cancel_at: null,
|
||||
cancel_at_period_end: false,
|
||||
trial_end: null,
|
||||
start_date: Math.floor((Date.now() - 200 * ONE_DAY_MS) / 1000),
|
||||
items: {data: []},
|
||||
} as unknown as Stripe.Subscription;
|
||||
}
|
||||
|
||||
function createPaidInvoice(periodEndMs: number): Stripe.Invoice {
|
||||
return {
|
||||
id: 'in_test',
|
||||
status: 'paid',
|
||||
lines: {data: [{period: {start: 0, end: Math.floor(periodEndMs / 1000)}}]},
|
||||
} as unknown as Stripe.Invoice;
|
||||
}
|
||||
|
||||
function createStripeStub(subscription: Stripe.Subscription, invoices: Array<Stripe.Invoice>): Stripe {
|
||||
return {
|
||||
subscriptions: {
|
||||
retrieve: async () => subscription,
|
||||
list: async () => ({data: [subscription]}),
|
||||
},
|
||||
invoices: {
|
||||
list: async () => ({data: invoices}),
|
||||
},
|
||||
} as unknown as Stripe;
|
||||
}
|
||||
|
||||
function createCapturingDeps(user: User): {
|
||||
userRepository: UserRepository;
|
||||
patches: Array<Partial<UserRow>>;
|
||||
extras: Record<string, unknown>;
|
||||
} {
|
||||
const patches: Array<Partial<UserRow>> = [];
|
||||
const userRepository = {
|
||||
findUnique: async () => user,
|
||||
patchUpsert: async (_id: unknown, patch: Partial<UserRow>) => {
|
||||
patches.push(patch);
|
||||
return new User({...user.toRow(), ...patch});
|
||||
},
|
||||
} as unknown as UserRepository;
|
||||
const extras = {
|
||||
userCacheService: {setUserPartialResponseFromUserInBackground: () => {}},
|
||||
gatewayService: {dispatchPresence: async () => {}},
|
||||
};
|
||||
return {userRepository, patches, extras};
|
||||
}
|
||||
|
||||
function createHelpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
@@ -151,4 +225,78 @@ describe('processPremiumStateReconciliationQueue', () => {
|
||||
expect(findUniqueCalls).toBe(1);
|
||||
expect(await queueService.getQueueSize()).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps premium when a paid invoice covers a period beyond the cancellation', async () => {
|
||||
const queueService = createQueueService();
|
||||
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
|
||||
|
||||
const endedAtMs = Date.now() - 3 * ONE_DAY_MS;
|
||||
const paidThroughMs = Math.floor((Date.now() + 20 * ONE_DAY_MS) / 1000) * 1000;
|
||||
const user = createPremiumUser({premium_until: new Date(paidThroughMs)});
|
||||
const {userRepository, patches, extras} = createCapturingDeps(user);
|
||||
|
||||
setWorkerDependenciesForTest({
|
||||
premiumStateReconciliationQueueService: queueService,
|
||||
stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(paidThroughMs)]),
|
||||
userRepository,
|
||||
...extras,
|
||||
});
|
||||
|
||||
await processPremiumStateReconciliationQueue({}, createHelpers());
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
const patch = patches[0];
|
||||
expect(patch.premium_type).toBeUndefined();
|
||||
expect(patch.premium_since).toBeUndefined();
|
||||
expect(patch.premium_until).toBeUndefined();
|
||||
expect(patch.premium_will_cancel).toBe(true);
|
||||
});
|
||||
|
||||
test('extends premium to the paid period instead of the cancellation date', async () => {
|
||||
const queueService = createQueueService();
|
||||
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
|
||||
|
||||
const endedAtMs = Date.now() - 3 * ONE_DAY_MS;
|
||||
const paidThroughMs = Date.now() + 20 * ONE_DAY_MS;
|
||||
const user = createPremiumUser({premium_until: new Date(Date.now() + 5 * ONE_DAY_MS)});
|
||||
const {userRepository, patches, extras} = createCapturingDeps(user);
|
||||
|
||||
setWorkerDependenciesForTest({
|
||||
premiumStateReconciliationQueueService: queueService,
|
||||
stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(paidThroughMs)]),
|
||||
userRepository,
|
||||
...extras,
|
||||
});
|
||||
|
||||
await processPremiumStateReconciliationQueue({}, createHelpers());
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].premium_until).toEqual(new Date(Math.floor(paidThroughMs / 1000) * 1000));
|
||||
expect(patches[0].premium_will_cancel).toBe(true);
|
||||
expect(patches[0].premium_type).toBeUndefined();
|
||||
});
|
||||
|
||||
test('still strips premium when no paid invoice covers a future period', async () => {
|
||||
const queueService = createQueueService();
|
||||
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
|
||||
|
||||
const endedAtMs = Date.now() - 3 * ONE_DAY_MS;
|
||||
const paidThroughMs = Date.now() - 3 * ONE_DAY_MS;
|
||||
const user = createPremiumUser();
|
||||
const {userRepository, patches, extras} = createCapturingDeps(user);
|
||||
|
||||
setWorkerDependenciesForTest({
|
||||
premiumStateReconciliationQueueService: queueService,
|
||||
stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(paidThroughMs)]),
|
||||
userRepository,
|
||||
...extras,
|
||||
});
|
||||
|
||||
await processPremiumStateReconciliationQueue({}, createHelpers());
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].premium_type).toBeNull();
|
||||
expect(patches[0].premium_until).toBeNull();
|
||||
expect(patches[0].premium_since).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,5 +282,5 @@
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@12.4.2"
|
||||
"packageManager": "pnpm@11.27.0"
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export class Channel {
|
||||
this.instanceId = options?.instanceId ?? RuntimeConfig.localInstanceDomain;
|
||||
this.id = channel.id;
|
||||
this.guildId = channel.guild_id;
|
||||
this.name = channel.name;
|
||||
this.name = channel.name ?? undefined;
|
||||
noteText(this.name);
|
||||
this.topic = channel.topic ?? null;
|
||||
this.url = channel.url ?? null;
|
||||
@@ -223,7 +223,7 @@ export class Channel {
|
||||
{
|
||||
id: this.id,
|
||||
guild_id: updates.guild_id ?? this.guildId,
|
||||
name: updates.name ?? this.name,
|
||||
name: updates.name !== undefined ? updates.name : this.name,
|
||||
topic: updates.topic !== undefined ? updates.topic : this.topic,
|
||||
url: updates.url !== undefined ? updates.url : this.url,
|
||||
icon: updates.icon !== undefined ? updates.icon : this.icon,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
--picker-source-card-bg: #000;
|
||||
--picker-source-card-border: rgb(255 255 255 / 0.14);
|
||||
--picker-source-card-border-hover: rgb(255 255 255 / 0.26);
|
||||
--picker-source-card-text: rgb(244 246 248 / 0.94);
|
||||
--picker-source-card-muted: rgb(244 246 248 / 0.72);
|
||||
|
||||
box-sizing: border-box;
|
||||
@@ -104,7 +103,7 @@
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--picker-source-card-text);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -258,7 +257,7 @@
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--picker-source-card-text);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -14,7 +14,7 @@ ARG PUBLIC_ASSET_BASE_URL
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN npm install -g pnpm@12.4.2
|
||||
RUN npm install -g pnpm@11.27.0
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates build-essential clang \
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<developer id="app.fluxer">
|
||||
<name>Fluxer</name>
|
||||
</developer>
|
||||
<summary>Instant messaging and VoIP</summary>
|
||||
<summary>A chat app that puts you first</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>AGPL-3.0-or-later</project_license>
|
||||
<icon type="stock">app.fluxer.Fluxer</icon>
|
||||
@@ -42,14 +42,16 @@
|
||||
<color type="primary">#4641D9</color>
|
||||
</branding>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<screenshot type="default" xml:lang="en">
|
||||
<caption>The main Fluxer window.</caption>
|
||||
<image type="source" width="1920" height="1080">https://fluxerstatic.com/marketing/screenshots/desktop-1920w.avif</image>
|
||||
<image type="source" width="1920" height="1080">https://raw.githubusercontent.com/fluxerapp/fluxer/refs/heads/main/fluxer_static/marketing/screenshots/desktop-2560w.avif</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<url type="bugtracker">https://github.com/fluxerapp/fluxer/issues</url>
|
||||
<url type="homepage">https://fluxer.app</url>
|
||||
<url type="donation">https://fluxer.app/donate</url>
|
||||
<url type="contact">https://fluxer.app/company-information#contact</url>
|
||||
<url type="translate">https://weblate.fluxer.tools/</url>
|
||||
<url type="vcs-browser">https://github.com/fluxerapp/fluxer.git</url>
|
||||
<update_contact>[email protected]</update_contact>
|
||||
<provides>
|
||||
@@ -69,4 +71,14 @@
|
||||
<control>keyboard</control>
|
||||
<control>pointing</control>
|
||||
</supports>
|
||||
<releases>
|
||||
<release version="2026.920.144558" date="2026-09-20">
|
||||
<url type="details">https://github.com/fluxerapp/fluxer/compare/5d85e88532a6b2bc8fa95cd951c9f82d63841de1..c157ab5752084ba424b80b66f7b3b6ca4abba1e3</url>
|
||||
<description>
|
||||
<p>
|
||||
Release following the synchronization of stable with canary
|
||||
</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
|
||||
@@ -65,6 +65,7 @@ before(async () => {
|
||||
files: {
|
||||
appimage: {url: `${baseUrl}/appimage`, sha256: NEW_SHA256},
|
||||
deb: {url: `${baseUrl}/deb`, sha256: 'deadbeef'},
|
||||
setup: {url: `${baseUrl}/setup`, sha256: 'cafebabe'},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -106,13 +107,35 @@ function createInstall() {
|
||||
return {applications, installedPath, mount};
|
||||
}
|
||||
|
||||
function loadUpdater({appImagePath, version = CURRENT_VERSION, appDir, execPath, stagingErrno}) {
|
||||
function loadUpdater({
|
||||
appImagePath,
|
||||
version = CURRENT_VERSION,
|
||||
appDir,
|
||||
execPath,
|
||||
stagingErrno,
|
||||
platform = 'linux',
|
||||
arch = 'arm64',
|
||||
velopack,
|
||||
applyAttempt = null,
|
||||
}) {
|
||||
const events = [];
|
||||
const handlers = new Map();
|
||||
const appEvents = new Map();
|
||||
const state = {relaunched: false, electronRelaunched: false};
|
||||
const applyState = {attempt: applyAttempt, recorded: [], cleared: 0};
|
||||
const module = {exports: {}};
|
||||
const stubs = {
|
||||
'@electron/main/UpdaterApplyState': {
|
||||
readVelopackApplyAttempt: () => applyState.attempt,
|
||||
recordVelopackApplyAttempt: (recordedVersion) => {
|
||||
applyState.recorded.push(recordedVersion);
|
||||
applyState.attempt = {version: recordedVersion, attemptedAt: 0};
|
||||
},
|
||||
clearVelopackApplyAttempt: () => {
|
||||
applyState.cleared += 1;
|
||||
applyState.attempt = null;
|
||||
},
|
||||
},
|
||||
'@electron/common/BuildChannel': {BUILD_CHANNEL: 'canary'},
|
||||
'@electron/common/UserDataPath': {isPortableMode: () => false},
|
||||
'@electron/main/DesktopTray': {destroyDesktopTray() {}},
|
||||
@@ -149,8 +172,8 @@ function loadUpdater({appImagePath, version = CURRENT_VERSION, appDir, execPath,
|
||||
Buffer,
|
||||
process: {
|
||||
...process,
|
||||
platform: 'linux',
|
||||
arch: 'arm64',
|
||||
platform,
|
||||
arch,
|
||||
execPath: execPath ?? (appDir ? join(appDir, 'fluxer-canary') : process.execPath),
|
||||
env: {
|
||||
...(appImagePath ? {APPIMAGE: appImagePath} : {}),
|
||||
@@ -159,11 +182,18 @@ function loadUpdater({appImagePath, version = CURRENT_VERSION, appDir, execPath,
|
||||
},
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setImmediate,
|
||||
fetch: (input, init) => {
|
||||
const url = String(input).replace('https://pkgs.fluxer.com/desktop/canary/linux/arm64', baseUrl);
|
||||
const url = String(input).replace(/https:\/\/pkgs\.fluxer\.com\/desktop\/canary\/[^/]+\/[^/]+/, baseUrl);
|
||||
return fetch(url, init);
|
||||
},
|
||||
require: (specifier) => {
|
||||
if (specifier === 'node:module' && velopack) {
|
||||
return {
|
||||
...require('node:module'),
|
||||
createRequire: () => (name) => (name === 'velopack' ? velopack : require(name)),
|
||||
};
|
||||
}
|
||||
if (specifier === 'node:fs/promises' && stagingErrno) {
|
||||
return {
|
||||
...require(specifier),
|
||||
@@ -200,6 +230,7 @@ function loadUpdater({appImagePath, version = CURRENT_VERSION, appDir, execPath,
|
||||
|
||||
module.exports.registerUpdater(() => ({webContents: {send: (_channel, event) => events.push(event)}}));
|
||||
return {
|
||||
applyState,
|
||||
events,
|
||||
state,
|
||||
check: () => handlers.get('updater-check')({}, 'user'),
|
||||
@@ -446,3 +477,93 @@ describe('Updater AppImage lifecycle', () => {
|
||||
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
|
||||
});
|
||||
});
|
||||
|
||||
function createVelopackStub({installedVersion, pendingRestart = null, remoteUpdate = null}) {
|
||||
const applied = [];
|
||||
class UpdateManager {
|
||||
getCurrentVersion() {
|
||||
return installedVersion;
|
||||
}
|
||||
getUpdatePendingRestart() {
|
||||
return pendingRestart;
|
||||
}
|
||||
checkForUpdatesAsync() {
|
||||
return Promise.resolve(remoteUpdate);
|
||||
}
|
||||
downloadUpdateAsync() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
waitExitThenApplyUpdate(update) {
|
||||
applied.push(update);
|
||||
}
|
||||
}
|
||||
return {applied, module: {UpdateManager}};
|
||||
}
|
||||
|
||||
function loadWindowsUpdater({installedVersion, pendingRestart, remoteUpdate, applyAttempt = null}) {
|
||||
const velopack = createVelopackStub({installedVersion, pendingRestart, remoteUpdate});
|
||||
const updater = loadUpdater({
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
velopack: velopack.module,
|
||||
applyAttempt,
|
||||
});
|
||||
return {...updater, applied: velopack.applied};
|
||||
}
|
||||
|
||||
describe('Updater Windows apply failures', () => {
|
||||
test('offers the installer when a downloaded update never applied', async () => {
|
||||
const updater = loadWindowsUpdater({
|
||||
installedVersion: CURRENT_VERSION,
|
||||
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
|
||||
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
|
||||
});
|
||||
|
||||
await updater.check();
|
||||
|
||||
assert.deepEqual(types(updater.events), ['checking', 'error', 'available']);
|
||||
assert.equal(updater.events[1].phase, 'install');
|
||||
assert.ok(updater.events[1].message.includes(PUBLISHED_VERSION));
|
||||
assert.equal(updater.events[2].downloadStarted, false);
|
||||
assert.equal(updater.events[2].downloadUrl, `${baseUrl}/setup`);
|
||||
assert.equal(updater.applyState.attempt.version, PUBLISHED_VERSION);
|
||||
});
|
||||
|
||||
test('resumes normal updates once the installed version catches up', async () => {
|
||||
const updater = loadWindowsUpdater({
|
||||
installedVersion: PUBLISHED_VERSION,
|
||||
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
|
||||
});
|
||||
|
||||
await updater.check();
|
||||
|
||||
assert.equal(updater.applyState.cleared, 1);
|
||||
assert.equal(updater.applyState.attempt, null);
|
||||
assert.deepEqual(types(updater.events), ['checking', 'not-available']);
|
||||
});
|
||||
|
||||
test('records the version it hands to the updater before quitting', async () => {
|
||||
const updater = loadWindowsUpdater({
|
||||
installedVersion: CURRENT_VERSION,
|
||||
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
|
||||
});
|
||||
|
||||
await updater.install();
|
||||
|
||||
assert.deepEqual(updater.applyState.recorded, [PUBLISHED_VERSION]);
|
||||
assert.deepEqual(updater.applied, [{Version: PUBLISHED_VERSION, Size: 100}]);
|
||||
});
|
||||
|
||||
test('refuses to re-apply a version that already failed to install', async () => {
|
||||
const updater = loadWindowsUpdater({
|
||||
installedVersion: CURRENT_VERSION,
|
||||
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
|
||||
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
|
||||
});
|
||||
|
||||
await assert.rejects(() => updater.install(), /Download the installer/);
|
||||
|
||||
assert.deepEqual(updater.applied, []);
|
||||
assert.deepEqual(updater.applyState.recorded, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
import {destroyDesktopTray} from '@electron/main/DesktopTray';
|
||||
import {isFlatpakRuntime} from '@electron/main/LinuxSandbox';
|
||||
import {relaunchAndExit} from '@electron/main/Troubleshooting';
|
||||
import {
|
||||
clearVelopackApplyAttempt,
|
||||
readVelopackApplyAttempt,
|
||||
recordVelopackApplyAttempt,
|
||||
type VelopackApplyAttempt,
|
||||
} from '@electron/main/UpdaterApplyState';
|
||||
import {
|
||||
buildManualVersionDownloadUrl,
|
||||
DOWNLOAD_PAGE_URL,
|
||||
@@ -149,6 +155,60 @@ function createVelopackUpdateManager() {
|
||||
return new UpdateManager(UPDATE_BASE_URL);
|
||||
}
|
||||
|
||||
type VelopackUpdateManager = ReturnType<typeof createVelopackUpdateManager>;
|
||||
|
||||
function getInstalledVelopackVersion(updateManager: VelopackUpdateManager): string | null {
|
||||
try {
|
||||
const version = updateManager.getCurrentVersion();
|
||||
return typeof version === 'string' && version.length > 0 ? version : null;
|
||||
} catch (error) {
|
||||
log.warn('Failed to read the installed Velopack version', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFailedVelopackApply(updateManager: VelopackUpdateManager): VelopackApplyAttempt | null {
|
||||
const attempt = readVelopackApplyAttempt();
|
||||
if (!attempt) {
|
||||
return null;
|
||||
}
|
||||
const installedVersion = getInstalledVelopackVersion(updateManager) ?? app.getVersion();
|
||||
if (compareVersions(installedVersion, attempt.version) >= 0) {
|
||||
clearVelopackApplyAttempt();
|
||||
return null;
|
||||
}
|
||||
return attempt;
|
||||
}
|
||||
|
||||
async function sendVelopackApplyFailure(
|
||||
context: UpdaterContext,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
attempt: VelopackApplyAttempt,
|
||||
): Promise<void> {
|
||||
log.error('A downloaded update was never applied, so the installer is offered instead.', attempt);
|
||||
send(getMainWindow(), {
|
||||
type: 'error',
|
||||
context,
|
||||
phase: 'install',
|
||||
message: `Fluxer could not finish installing version ${attempt.version}.`,
|
||||
});
|
||||
try {
|
||||
const latest = await fetchManualLatest({forceRefresh: true});
|
||||
sendManualUpdateAvailable(getMainWindow, context, latest);
|
||||
return;
|
||||
} catch (error) {
|
||||
log.warn('Failed to resolve the installer download after a failed update apply', error);
|
||||
}
|
||||
send(getMainWindow(), {
|
||||
type: 'available',
|
||||
context,
|
||||
version: attempt.version,
|
||||
downloadSize: null,
|
||||
downloadStarted: false,
|
||||
downloadUrl: buildManualVersionDownloadUrl(attempt.version, 'setup'),
|
||||
});
|
||||
}
|
||||
|
||||
async function checkVelopackForUpdates(
|
||||
context: UpdaterContext,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
@@ -160,6 +220,11 @@ async function checkVelopackForUpdates(
|
||||
try {
|
||||
send(getMainWindow(), {type: 'checking', context});
|
||||
const updateManager = createVelopackUpdateManager();
|
||||
const failedApply = resolveFailedVelopackApply(updateManager);
|
||||
if (failedApply) {
|
||||
await sendVelopackApplyFailure(context, getMainWindow, failedApply);
|
||||
return;
|
||||
}
|
||||
const pendingUpdate = updateManager.getUpdatePendingRestart();
|
||||
const update = await updateManager.checkForUpdatesAsync();
|
||||
if (!update) {
|
||||
@@ -300,6 +365,13 @@ function installVelopackUpdate(): void {
|
||||
if (!update) {
|
||||
throw new Error('No Velopack update is ready to install.');
|
||||
}
|
||||
if (resolveFailedVelopackApply(updateManager)) {
|
||||
throw new Error('The last update could not be installed. Download the installer to update.');
|
||||
}
|
||||
const updateVersion = getVelopackUpdateVersion(update);
|
||||
if (updateVersion) {
|
||||
recordVelopackApplyAttempt(updateVersion);
|
||||
}
|
||||
velopackInstallStarted = true;
|
||||
setQuitting(true);
|
||||
destroyDesktopTray();
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import path from 'node:path';
|
||||
import {describe, test} from 'node:test';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
const sourcePath = fileURLToPath(new URL('./UpdaterApplyState.ts', import.meta.url));
|
||||
const source = readFileSync(sourcePath, 'utf8');
|
||||
const transformedSource = esbuild.transformSync(source, {
|
||||
loader: 'ts',
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
}).code;
|
||||
|
||||
const USER_DATA = 'C:\\Users\\csh\\AppData\\Roaming\\fluxercanary';
|
||||
const STATE_PATH = path.win32.join(USER_DATA, 'update-apply-state.json');
|
||||
|
||||
function loadApplyState(initialFiles = []) {
|
||||
const files = new Map(initialFiles);
|
||||
const warnings = [];
|
||||
const fakeFs = {
|
||||
readFileSync: (target) => {
|
||||
if (!files.has(target)) throw new Error(`ENOENT ${target}`);
|
||||
return files.get(target);
|
||||
},
|
||||
writeFileSync: (target, contents) => {
|
||||
files.set(target, contents);
|
||||
},
|
||||
rmSync: (target) => {
|
||||
files.delete(target);
|
||||
},
|
||||
};
|
||||
|
||||
const module = {exports: {}};
|
||||
const context = vm.createContext({
|
||||
module,
|
||||
exports: module.exports,
|
||||
console,
|
||||
Date,
|
||||
JSON,
|
||||
require: (specifier) => {
|
||||
if (specifier === 'node:fs') {
|
||||
return {default: fakeFs, ...fakeFs};
|
||||
}
|
||||
if (specifier === 'node:path') {
|
||||
return {default: path.win32, ...path.win32};
|
||||
}
|
||||
if (specifier === 'electron') {
|
||||
return {app: {getPath: () => USER_DATA}};
|
||||
}
|
||||
if (specifier === 'electron-log') {
|
||||
const log = {warn: (...args) => warnings.push(args)};
|
||||
return {default: log, ...log};
|
||||
}
|
||||
return require(specifier);
|
||||
},
|
||||
});
|
||||
vm.runInContext(transformedSource, context, {filename: sourcePath});
|
||||
return {...module.exports, files, warnings};
|
||||
}
|
||||
|
||||
describe('update apply attempt state', () => {
|
||||
test('records an attempt that reads back with its version', () => {
|
||||
const harness = loadApplyState();
|
||||
|
||||
harness.recordVelopackApplyAttempt('2026.920.144552');
|
||||
|
||||
const attempt = harness.readVelopackApplyAttempt();
|
||||
assert.equal(attempt.version, '2026.920.144552');
|
||||
assert.equal(typeof attempt.attemptedAt, 'number');
|
||||
assert.equal(harness.files.has(STATE_PATH), true);
|
||||
});
|
||||
|
||||
test('reads nothing when no attempt was ever recorded', () => {
|
||||
const harness = loadApplyState();
|
||||
|
||||
assert.equal(harness.readVelopackApplyAttempt(), null);
|
||||
assert.deepEqual(harness.warnings, []);
|
||||
});
|
||||
|
||||
test('clearing an attempt removes the state file', () => {
|
||||
const harness = loadApplyState();
|
||||
harness.recordVelopackApplyAttempt('2026.920.144552');
|
||||
|
||||
harness.clearVelopackApplyAttempt();
|
||||
|
||||
assert.equal(harness.files.has(STATE_PATH), false);
|
||||
assert.equal(harness.readVelopackApplyAttempt(), null);
|
||||
});
|
||||
|
||||
test('treats a corrupt state file as no attempt', () => {
|
||||
const harness = loadApplyState([[STATE_PATH, '{not json']]);
|
||||
|
||||
assert.equal(harness.readVelopackApplyAttempt(), null);
|
||||
assert.equal(harness.warnings.length, 1);
|
||||
});
|
||||
|
||||
test('treats a state file without a version as no attempt', () => {
|
||||
const harness = loadApplyState([[STATE_PATH, JSON.stringify({attemptedAt: 1})]]);
|
||||
|
||||
assert.equal(harness.readVelopackApplyAttempt(), null);
|
||||
});
|
||||
|
||||
test('defaults a missing timestamp so an old state file still blocks the loop', () => {
|
||||
const harness = loadApplyState([[STATE_PATH, JSON.stringify({version: '2026.920.144552'})]]);
|
||||
|
||||
const attempt = harness.readVelopackApplyAttempt();
|
||||
assert.equal(attempt.version, '2026.920.144552');
|
||||
assert.equal(attempt.attemptedAt, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {app} from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
const APPLY_STATE_FILE_NAME = 'update-apply-state.json';
|
||||
|
||||
export type VelopackApplyAttempt = {
|
||||
version: string;
|
||||
attemptedAt: number;
|
||||
};
|
||||
|
||||
function getApplyStatePath(): string {
|
||||
return path.join(app.getPath('userData'), APPLY_STATE_FILE_NAME);
|
||||
}
|
||||
|
||||
export function readVelopackApplyAttempt(): VelopackApplyAttempt | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(getApplyStatePath(), 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {version?: unknown; attemptedAt?: unknown};
|
||||
if (typeof parsed.version !== 'string' || parsed.version.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: parsed.version,
|
||||
attemptedAt: typeof parsed.attemptedAt === 'number' ? parsed.attemptedAt : 0,
|
||||
};
|
||||
} catch (error) {
|
||||
log.warn('Failed to parse the recorded update apply attempt', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function recordVelopackApplyAttempt(version: string): void {
|
||||
const payload: VelopackApplyAttempt = {version, attemptedAt: Date.now()};
|
||||
try {
|
||||
fs.writeFileSync(getApplyStatePath(), JSON.stringify(payload), 'utf8');
|
||||
} catch (error) {
|
||||
log.warn('Failed to record the update apply attempt', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearVelopackApplyAttempt(): void {
|
||||
try {
|
||||
fs.rmSync(getApplyStatePath(), {force: true});
|
||||
} catch (error) {
|
||||
log.warn('Failed to clear the recorded update apply attempt', error);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ FROM node:26-trixie-slim AS base
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN npm install -g pnpm@12.4.2
|
||||
RUN npm install -g pnpm@11.27.0
|
||||
|
||||
FROM base AS deploy
|
||||
|
||||
@@ -36,7 +36,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libvips42t64 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g pnpm@12.4.2
|
||||
RUN npm install -g pnpm@11.27.0
|
||||
|
||||
LABEL maintainer="Fluxer"
|
||||
LABEL org.opencontainers.image.title="fluxer-docs"
|
||||
|
||||
@@ -28,9 +28,12 @@ One repository serves Fedora and the RHEL family, split by channel and architect
|
||||
```
|
||||
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo \
|
||||
https://pkgs.fluxer.com/rpm/fluxer.repo
|
||||
sudo rpm --import https://pkgs.fluxer.com/keys/fluxer-archive-keyring.asc
|
||||
sudo dnf install fluxer
|
||||
```
|
||||
|
||||
The `.repo` file names the signing key by URL, so dnf fetches it rather than needing the keyring step the apt entry has. Without the `rpm --import` line dnf asks to import twice on a first install, once for the repository metadata and once for the package. With it dnf asks once, for the metadata, which dnf keeps in its own key store. Both prompts print the fingerprint, which reads `09D01339EE128925F75E675C855C5BDE34D205D2`.
|
||||
|
||||
Metadata expires after six hours, so a freshly published build becomes visible within that window, or immediately with `dnf --refresh upgrade`.
|
||||
|
||||
:::caution[RHEL, Rocky, Alma and CentOS Stream need EPEL]
|
||||
@@ -89,4 +92,4 @@ Once the remote exists, either application installs by id:
|
||||
flatpak install fluxer app.fluxer.FluxerCanary
|
||||
```
|
||||
|
||||
The reference file names no signing key, so flatpak configures the remote unverified and no flag is required. Adding the remote by URL instead of by reference file does need `--no-gpg-verify`.
|
||||
The reference file names the signing key, so flatpak configures the remote verified and imports the key. `fluxer.flatpakrepo` does the same, so `flatpak remote-add` from that URL needs no flag either. Adding the bare repository URL skips both files: flatpak still turns verification on but has no key to check against, so that route needs `--gpg-import` or `--no-gpg-verify`.
|
||||
|
||||
+1
-1
@@ -57,5 +57,5 @@
|
||||
"typescript-eslint": "catalog:",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@12.4.2"
|
||||
"packageManager": "pnpm@11.27.0"
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export type CallEligibilityResponse = z.infer<typeof CallEligibilityResponse>;
|
||||
export const ChannelResponse = z.object({
|
||||
id: SnowflakeStringType.describe('The unique identifier (snowflake) for this channel'),
|
||||
guild_id: SnowflakeStringType.optional().describe('The ID of the guild this channel belongs to'),
|
||||
name: z.string().optional().describe('The name of the channel'),
|
||||
name: z.string().nullish().describe('The name of the channel'),
|
||||
topic: z.string().nullish().describe('The topic of the channel'),
|
||||
url: z.url().nullish().describe('The URL associated with the channel'),
|
||||
icon: z.string().nullish().describe('The icon hash of the channel (for group DMs)'),
|
||||
@@ -141,7 +141,7 @@ export interface ChannelOverwrite {
|
||||
export interface Channel {
|
||||
readonly id: string;
|
||||
readonly guild_id?: string;
|
||||
readonly name?: string;
|
||||
readonly name?: string | null;
|
||||
readonly topic?: string | null;
|
||||
readonly url?: string | null;
|
||||
readonly icon?: string | null;
|
||||
|
||||
Generated
-158
@@ -1,161 +1,3 @@
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
pnpm:
|
||||
specifier: 12.4.2
|
||||
version: 12.4.2
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-E255MbcQ0V1577M2BV0ajWuP7KmTPYA0jqZnk3rK6Lq3kTvZWulMMb7iqRmnLsQbyWTkMEB2CfyM70loVDA8xg==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-J1pSeCUwuKxMG70ZzpWn8JzElxiEa8NN/3N0SlZRtU2xbztChaJCKF3HaBNIj9yPfeofWzJo/lwNvDDjraQuZw==}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-A0WDo8iErfZBXgrLseQxw8i8Y9ctUpOEl/Uu+cubnTzpD8tT9ykIB548L8YTM2WD4OS+ZOHSxy8aGZcvKq8PaQ==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-MSgJdovBWHcb5DEOvfPH9yNi/T5O1Xa4ess+E1ESGo/5yuty7S4JoiIjami+fsNXfnoQMlwsMUqYU4zAvBcNCw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-h2YumlQSNvgbRPv+RXwABohX65f9bOBZn+jMIt7bFDISZPCzQ+Nvpt6Awbp4ip5PwQgYxbu5iREJ1fHE39Fm8A==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-LwSEtSEDTv6S51YLs3YvSkPyun/QmfMic1UGICUkPWFu6ByP43RdMlkKvmVkfGhAYCpnxO057vrmyJqtfZrPCA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-2dSiDXyhx+RTHsewxex8f/jVjqQXWJ2oow4kCVHEWdZKeBpgMxvZ6fHkTAUBJXgqhbKIDHvuNlZBP7gJfUWL5A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-8Itc+jQk+MTz04LS9D1RcH+VctAWmzM4l1cJQ+Sx7pAJNo7EKHdRMbC0Tok1jyQ7eEHNJZD5EleYneZqWfZM+g==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-hleOeqhTVpH+z9RVMGnxvU4ZnrkBUClWjCbHD9u6kwyqhGSpevoU1wTGish+CBRhmIgMAy9pAfFpqhbAKOKNfw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-LAsQRRdP9aToENR6dtcIJ9l+e1zMYOX0tQcLGpRyLPVBQcYRLlvAPcmDshsiIHQjp05SDa9FI0czX1ZQ1a7a/A==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-kzfzH2/0BWdTABK14Yj5a1xsdkTEQUp2eXEPNakaD9jKL025lq3hyaKHIz/gIZaPDMe/1bFFK/En4ztFlbBJxw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-/pbt0UVTa8NMDhzOWLQRfZ6G9ROKXlJPZtx845BqyWfc7hCrWXhTLBd70yO2y8+E+IWN3oHM1s/JsIQNGk1yvg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-PsW19e4dAUNpZ0cS9flaxFuAmpt2dKlH/Vvi8TZ4qyJjjQzue/CEsWm+6wKVP4CJ7IT8RdL4qh+soOPWIgF2Zw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-+xGoeE0g55ztWvl8i5QqdmNfW3nIrTVcoQrNshEOwxthm9Ag48oXton3uxOA3/SANyz5AXexEjj2KO20NnOrEw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CK3GYTGAJ1x8ntraOdzwjJxhrU5+rzMKTzRh8QKw+QdCNFTRF/mOctR/7wYWBwZE17/8lzpqV/UJCm18NosHyQ==}
|
||||
engines: {node: '>=18.*'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@pnpm/exe.android-arm64': 12.4.2
|
||||
'@pnpm/exe.android-x64': 12.4.2
|
||||
'@pnpm/exe.darwin-arm64': 12.4.2
|
||||
'@pnpm/exe.darwin-x64': 12.4.2
|
||||
'@pnpm/exe.freebsd-x64': 12.4.2
|
||||
'@pnpm/exe.linux-arm64': 12.4.2
|
||||
'@pnpm/exe.linux-arm64-musl': 12.4.2
|
||||
'@pnpm/exe.linux-ppc64': 12.4.2
|
||||
'@pnpm/exe.linux-riscv64': 12.4.2
|
||||
'@pnpm/exe.linux-s390x': 12.4.2
|
||||
'@pnpm/exe.linux-x64': 12.4.2
|
||||
'@pnpm/exe.linux-x64-musl': 12.4.2
|
||||
'@pnpm/exe.win32-arm64': 12.4.2
|
||||
'@pnpm/exe.win32-x64': 12.4.2
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
|
||||
Reference in New Issue
Block a user