Reconnection when signal server is not active and minor changes

This commit is contained in:
2026-03-18 20:45:00 +01:00
parent eb987ac672
commit 141de64767
15 changed files with 229 additions and 27 deletions

View File

@@ -4,9 +4,24 @@ import { createWindow, getMainWindow } from '../window/create-window';
const CUSTOM_PROTOCOL = 'toju';
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
let pendingDeepLink: string | null = null;
function resolveDevSingleInstanceExitCode(): number | null {
const rawValue = process.env[DEV_SINGLE_INSTANCE_EXIT_CODE_ENV];
if (!rawValue) {
return null;
}
const parsedValue = Number.parseInt(rawValue, 10);
return Number.isInteger(parsedValue) && parsedValue > 0
? parsedValue
: null;
}
function extractDeepLink(argv: string[]): string | null {
return argv.find((argument) => typeof argument === 'string' && argument.startsWith(DEEP_LINK_PREFIX)) || null;
}
@@ -60,7 +75,14 @@ export function initializeDeepLinkHandling(): boolean {
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.quit();
const devExitCode = resolveDevSingleInstanceExitCode();
if (devExitCode != null) {
app.exit(devExitCode);
} else {
app.quit();
}
return false;
}

View File

@@ -4,6 +4,10 @@ function buildOrigin(protocol: string, host: string): string {
return `${protocol}://${host}`.replace(/\/+$/, '');
}
function originFromUrl(url: URL): string {
return buildOrigin(url.protocol.replace(':', ''), url.host);
}
export function getRequestOrigin(request: Request): string {
const forwardedProtoHeader = request.get('x-forwarded-proto');
const forwardedHostHeader = request.get('x-forwarded-host');
@@ -15,18 +19,24 @@ export function getRequestOrigin(request: Request): string {
export function deriveWebAppOrigin(signalOrigin: string): string {
const url = new URL(signalOrigin);
const host = url.host;
if (host === 'signal.toju.app') {
if (url.hostname === 'signal.toju.app' && !url.port) {
return 'https://web.toju.app';
}
if (host.startsWith('signal.')) {
return buildOrigin(url.protocol.replace(':', ''), host.replace(/^signal\./, 'web.'));
if (url.hostname.startsWith('signal.')) {
url.hostname = url.hostname.replace(/^signal\./, 'web.');
if (url.port === '3001') {
url.port = '4200';
}
return originFromUrl(url);
}
if (['localhost:3001', '127.0.0.1:3001'].includes(host)) {
return buildOrigin(url.protocol.replace(':', ''), host.replace(/:3001$/, ':4200'));
if (url.port === '3001') {
url.port = '4200';
return originFromUrl(url);
}
return 'https://web.toju.app';

View File

@@ -14,6 +14,7 @@ import {
buildSignalingUrl,
createServerInvite,
joinServerWithAccess,
leaveServerUser,
passwordHashForInput,
ServerAccessError,
kickServerUser,
@@ -341,6 +342,24 @@ router.post('/:id/moderation/unban', async (req, res) => {
res.json({ ok: true });
});
router.post('/:id/leave', async (req, res) => {
const { id: serverId } = req.params;
const { userId } = req.body;
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (!userId) {
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
}
await leaveServerUser(serverId, String(userId));
res.json({ ok: true });
});
router.post('/:id/heartbeat', async (req, res) => {
const { id } = req.params;
const { currentUsers } = req.body;

View File

@@ -63,6 +63,10 @@ function normalizePassword(password?: string | null): string | null {
return normalized.length > 0 ? normalized : null;
}
function isServerOwner(server: ServerEntity, userId: string): boolean {
return server.ownerId === userId;
}
export function hashServerPassword(password: string): string {
return crypto.createHash('sha256').update(password)
.digest('hex');
@@ -231,6 +235,18 @@ export async function joinServerWithAccess(options: {
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot join this server');
}
if (isServerOwner(server, options.userId)) {
const existingMembership = await findServerMembership(server.id, options.userId);
await ensureServerMembership(server.id, options.userId);
return {
joinedBefore: !!existingMembership,
server: rowToServer(server),
via: 'membership'
};
}
if (options.inviteId) {
const inviteBundle = await getActiveServerInvite(options.inviteId);
@@ -305,6 +321,11 @@ export async function authorizeWebSocketJoin(serverId: string, userId: string):
reason: 'BANNED' };
}
if (isServerOwner(server, userId)) {
await ensureServerMembership(serverId, userId);
return { allowed: true };
}
const membership = await findServerMembership(serverId, userId);
if (membership) {
@@ -327,6 +348,10 @@ export async function kickServerUser(serverId: string, userId: string): Promise<
await removeServerMembership(serverId, userId);
}
export async function leaveServerUser(serverId: string, userId: string): Promise<void> {
await removeServerMembership(serverId, userId);
}
export async function banServerUser(options: BanServerUserOptions): Promise<ServerBanEntity> {
await removeServerMembership(options.serverId, options.userId);

View File

@@ -563,10 +563,10 @@ export class ServerDirectoryService {
);
}
/** Notify the directory that a user has left a server. */
notifyLeave(serverId: string, userId: string): Observable<void> {
/** Remove a user's remembered membership after leaving a server. */
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http
.post<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/leave`, { userId })
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId })
.pipe(
catchError((error) => {
console.error('Failed to notify leave:', error);

View File

@@ -2,7 +2,9 @@
<div class="mx-auto flex min-h-[calc(100vh-8rem)] max-w-4xl items-center justify-center">
<div class="w-full overflow-hidden rounded-3xl border border-border bg-card/90 shadow-2xl backdrop-blur">
<div class="border-b border-border bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 px-6 py-8 sm:px-10">
<div class="inline-flex items-center rounded-full border border-border bg-secondary/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-muted-foreground">
<div
class="inline-flex items-center rounded-full border border-border bg-secondary/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-muted-foreground"
>
Invite link
</div>
<h1 class="mt-4 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
@@ -51,9 +53,7 @@
@if (invite()!.server.hasPassword) {
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Password bypassed by invite</span>
}
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-primary">
Expires {{ invite()!.expiresAt | date:'medium' }}
</span>
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-primary"> Expires {{ invite()!.expiresAt | date: 'medium' }} </span>
</div>
</div>
}

View File

@@ -165,9 +165,7 @@
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
</div>
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
<div class="text-muted-foreground">
Access: <span class="text-foreground/80">Password required</span>
</div>
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
}
</div>
</button>
@@ -327,9 +325,7 @@
id="create-server-password"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<p class="mt-1 text-xs text-muted-foreground">
Users who already joined keep access even if you change the password later.
</p>
<p class="mt-1 text-xs text-muted-foreground">Users who already joined keep access even if you change the password later.</p>
</div>
</div>

View File

@@ -123,6 +123,7 @@ export class ServersRailComponent {
this.prepareVoiceContext(room);
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
this.store.dispatch(RoomsActions.viewServer({ room }));
} else {
await this.attemptJoinRoom(room);
@@ -301,10 +302,31 @@ export class ServersRailComponent {
if (errorCode === 'BANNED') {
this.bannedServerName.set(room.name);
this.showBannedDialog.set(true);
return;
}
if (this.shouldFallbackToOfflineView(error)) {
this.closePasswordDialog();
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
this.store.dispatch(RoomsActions.viewServer({ room }));
}
}
}
private shouldFallbackToOfflineView(error: unknown): boolean {
const serverError = error as {
error?: { errorCode?: string };
status?: number;
};
const errorCode = serverError?.error?.errorCode;
const status = serverError?.status;
return errorCode === 'SERVER_NOT_FOUND'
|| status === 0
|| status === 404
|| (typeof status === 'number' && status >= 500);
}
private toServerInfo(room: Room) {
return {
id: room.id,

View File

@@ -13,6 +13,16 @@
/>
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
@if (showRoomReconnectNotice()) {
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
<ng-icon
name="lucideRefreshCw"
class="h-3.5 w-3.5 animate-spin"
/>
Reconnecting to signal server…
</span>
}
@if (roomDescription()) {
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
{{ roomDescription() }}

View File

@@ -15,10 +15,14 @@ import {
lucideX,
lucideChevronLeft,
lucideHash,
lucideMenu
lucideMenu,
lucideRefreshCw
} from '@ng-icons/lucide';
import { Router } from '@angular/router';
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import {
selectCurrentRoom,
selectIsSignalServerReconnecting
} from '../../store/rooms/rooms.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
@@ -52,7 +56,8 @@ type ElectronWindow = Window & {
lucideX,
lucideChevronLeft,
lucideHash,
lucideMenu })
lucideMenu,
lucideRefreshCw })
],
templateUrl: './title-bar.component.html'
})
@@ -80,9 +85,17 @@ export class TitleBarComponent {
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
isAuthed = computed(() => !!this.currentUser());
currentRoom = this.store.selectSignal(selectCurrentRoom);
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
inRoom = computed(() => !!this.currentRoom());
roomName = computed(() => this.currentRoom()?.name || '');
roomDescription = computed(() => this.currentRoom()?.description || '');
showRoomReconnectNotice = computed(() =>
this.inRoom() && (
this.isSignalServerReconnecting()
|| this.webrtc.shouldShowConnectionError()
|| this.isReconnecting()
)
);
private _showMenu = signal(false);
showMenu = computed(() => this._showMenu());
showLeaveConfirm = signal(false);

View File

@@ -67,6 +67,7 @@ export const RoomsActions = createActionGroup({
'Rename Channel': props<{ channelId: string; name: string }>(),
'Clear Search Results': emptyProps(),
'Set Connecting': props<{ isConnecting: boolean }>()
'Set Connecting': props<{ isConnecting: boolean }>(),
'Set Signal Server Reconnecting': props<{ isReconnecting: boolean }>()
}
});

View File

@@ -95,6 +95,7 @@ function isWrongServer(
interface RoomPresenceSignalingMessage {
type: string;
reason?: string;
serverId?: string;
users?: { oderId: string; displayName: string }[];
oderId?: string;
@@ -471,6 +472,15 @@ export class RoomsEffects {
});
}
if (currentUser && room) {
this.serverDirectory.notifyLeave(roomId, currentUser.id, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
}
// Delete from local DB
this.db.deleteRoom(roomId);
@@ -764,7 +774,11 @@ export class RoomsEffects {
})
);
return [UsersActions.clearUsers(), ...joinActions];
return [
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
UsersActions.clearUsers(),
...joinActions
];
}
case 'user_joined': {
@@ -780,6 +794,7 @@ export class RoomsEffects {
};
return [
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
UsersActions.userJoined({
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
})
@@ -794,7 +809,20 @@ export class RoomsEffects {
return EMPTY;
this.knownVoiceUsers.delete(signalingMessage.oderId);
return [UsersActions.userLeft({ userId: signalingMessage.oderId })];
return [
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
UsersActions.userLeft({ userId: signalingMessage.oderId })
];
}
case 'access_denied': {
if (isWrongServer(signalingMessage.serverId, viewedServerId))
return EMPTY;
if (signalingMessage.reason !== 'SERVER_NOT_FOUND')
return EMPTY;
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
}
default:

View File

@@ -82,6 +82,8 @@ export interface RoomsState {
isConnecting: boolean;
/** Whether the user is connected to a room. */
isConnected: boolean;
/** Whether the current room is using locally cached data while reconnecting. */
isSignalServerReconnecting: boolean;
/** Whether rooms are being loaded from local storage. */
loading: boolean;
/** Most recent error message, if any. */
@@ -98,6 +100,7 @@ export const initialState: RoomsState = {
isSearching: false,
isConnecting: false,
isConnected: false,
isSignalServerReconnecting: false,
loading: false,
error: null,
activeChannelId: 'general'
@@ -159,6 +162,7 @@ export const roomsReducer = createReducer(
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isSignalServerReconnecting: false,
isConnected: true,
activeChannelId: 'general'
};
@@ -185,6 +189,7 @@ export const roomsReducer = createReducer(
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isSignalServerReconnecting: false,
isConnected: true,
activeChannelId: 'general'
};
@@ -206,6 +211,7 @@ export const roomsReducer = createReducer(
...state,
currentRoom: null,
roomSettings: null,
isSignalServerReconnecting: false,
isConnecting: false,
isConnected: false
})),
@@ -279,6 +285,7 @@ export const roomsReducer = createReducer(
// Delete room
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
...state,
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
})),
@@ -286,6 +293,7 @@ export const roomsReducer = createReducer(
// Forget room (local only)
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
...state,
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
})),
@@ -295,6 +303,7 @@ export const roomsReducer = createReducer(
...state,
currentRoom: enrichRoom(room),
savedRooms: upsertRoom(state.savedRooms, room),
isSignalServerReconnecting: false,
isConnected: true
})),
@@ -303,6 +312,7 @@ export const roomsReducer = createReducer(
...state,
currentRoom: null,
roomSettings: null,
isSignalServerReconnecting: false,
isConnected: false
})),
@@ -367,6 +377,11 @@ export const roomsReducer = createReducer(
isConnecting
})),
on(RoomsActions.setSignalServerReconnecting, (state, { isReconnecting }) => ({
...state,
isSignalServerReconnecting: isReconnecting
})),
// Channel management
on(RoomsActions.selectChannel, (state, { channelId }) => ({
...state,

View File

@@ -26,6 +26,10 @@ export const selectIsConnected = createSelector(
selectRoomsState,
(state) => state.isConnected
);
export const selectIsSignalServerReconnecting = createSelector(
selectRoomsState,
(state) => state.isSignalServerReconnecting
);
export const selectRoomsError = createSelector(
selectRoomsState,
(state) => state.error

View File

@@ -1,5 +1,8 @@
const { spawn } = require('child_process');
const DEV_SINGLE_INSTANCE_EXIT_CODE = 23;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
function isWaylandSession(env) {
const sessionType = String(env.XDG_SESSION_TYPE || '').trim().toLowerCase();
@@ -44,11 +47,40 @@ function buildElectronArgs(argv) {
return args;
}
function isDevelopmentLaunch(env) {
return String(env.NODE_ENV || '').trim().toLowerCase() === 'development';
}
function buildChildEnv(env) {
const nextEnv = { ...env };
if (isDevelopmentLaunch(env)) {
nextEnv[DEV_SINGLE_INSTANCE_EXIT_CODE_ENV] = String(DEV_SINGLE_INSTANCE_EXIT_CODE);
}
return nextEnv;
}
function keepProcessAliveForExistingInstance() {
console.log(
'Electron is already running; keeping the dev services alive and routing links to the open window.'
);
const intervalId = setInterval(() => {}, 60_000);
const shutdown = () => {
clearInterval(intervalId);
process.exit(0);
};
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
}
function main() {
const electronBinary = resolveElectronBinary();
const args = buildElectronArgs(process.argv.slice(2));
const child = spawn(electronBinary, args, {
env: process.env,
env: buildChildEnv(process.env),
stdio: 'inherit'
});
@@ -58,6 +90,11 @@ function main() {
});
child.on('exit', (code, signal) => {
if (code === DEV_SINGLE_INSTANCE_EXIT_CODE) {
keepProcessAliveForExistingInstance();
return;
}
if (signal) {
process.kill(process.pid, signal);
return;