Compare commits

...
Author SHA1 Message Date
HampusandGitHub 7601bf98ee fix(channel): sync a cleared group DM name without a reload (#2882) 2026-09-21 15:34:18 +02:00
c7ec2a0f58 chore(tooling): Ignore .vscode/ in .gitignore (#2868)
Co-authored-by: Hampus <[email protected]>
2026-09-21 13:29:33 +02:00
XeonandGitHub 6a5e0056a8 fix(flatpak): Add a release tag and make small corrections (#2872) 2026-09-21 13:28:36 +02:00
HampusandGitHub 78d105b46e fix(desktop): stop looping on an update that never installs (#2879) 2026-09-21 03:36:11 +02:00
HampusandGitHub c68d62b8a0 fix(voice): darken screen share source titles in light theme (#2878) 2026-09-21 01:20:54 +02:00
HampusandGitHub df58020f4c fix(api): keep premium paid for after a subscription cancels (#2875) 2026-09-20 23:50:49 +02:00
HampusandGitHub f052ce05aa fix(workspace): point the Erlang extension at the repo root (#2871) 2026-09-20 19:49:06 +02:00
HampusandGitHub eedfd9275f fix(api): only require permissions a channel overwrite grants (#2867) 2026-09-20 17:56:22 +02:00
HampusandGitHub 416af4bec4 fix(docs): correct the flatpak and dnf signing instructions (#2865) 2026-09-20 16:42:33 +02:00
HampusandGitHub 108d282ddd chore(deps): pin pnpm 11 so the lockfile parses for packagers (#2864) 2026-09-20 15:30:50 +02:00
HampusandGitHub a6103244b0 docs(readme): fix the license wording and shrink the preview (#2862) 2026-09-20 15:11:06 +02:00
HampusandGitHub 38935c83c5 docs(readme): document every download and install method (#2861) 2026-09-20 15:05:54 +02:00
30 changed files with 914 additions and 215 deletions
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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",
+1
View File
@@ -10,6 +10,7 @@
/.direnv/
/.fluxer/
/.pnpm-store/
/.vscode/
**/*.css.d.ts
**/*.tsbuildinfo
+128 -3
View File
@@ -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/
+1 -1
View File
@@ -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]
+2 -2
View File
@@ -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
+1 -1
View File
@@ -94,5 +94,5 @@
"typescript": "catalog:ts7",
"vitest": "catalog:"
},
"packageManager": "pnpm@12.4.2"
"packageManager": "pnpm@11.27.0"
}
+1 -1
View File
@@ -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);
});
});
+1 -1
View File
@@ -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();
});
});
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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>
+125 -4
View File
@@ -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, []);
});
});
+72
View File
@@ -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);
}
}
+2 -2
View File
@@ -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
View File
@@ -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;
-158
View File
@@ -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: