9 Commits

Author SHA1 Message Date
7170d87bbd chore: bump faulty release 2026-06-13 19:39:42 +02:00
f0ac8ac951 feat: downloads now point to correct release 2026-06-13 17:45:14 +02:00
aad1c72bef feat: autoupdater 2026-06-13 17:21:04 +02:00
c61e6e3679 ci: remove forced pnpm version 2026-06-13 17:08:13 +02:00
21f7df22d5 chore: bump version 2026-06-13 17:03:57 +02:00
0744922d19 chore: dockerignore 2026-06-13 16:53:56 +02:00
a2414aee2f fix: builds 2026-06-13 16:41:10 +02:00
a90d7c8f8c refactor: streaming page and some other desktop app updats 2026-06-13 16:03:23 +02:00
1d926ce082 Merge pull request #3 from SrIzan10/feat/android-app
feat/android app
2026-04-23 22:52:13 +02:00
18 changed files with 994 additions and 182 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.nuxt
.output
.data
node_modules
dist
dist-electron
native-app
coverage
*.log
.env
.env.*
!.env.example

View File

@@ -30,8 +30,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4

View File

@@ -3,13 +3,13 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
# Copy package.json and your lockfile, here we add pnpm-lock.yaml for illustration
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm i
RUN pnpm install --frozen-lockfile
# Copy the entire project
COPY . ./

View File

@@ -14,6 +14,7 @@ import { useStreamerStore } from "~/state/streamer";
const { t } = useI18n();
const router = useRouter();
const selectedValue = ref("");
const modelValue = defineModel<string>({ default: "" });
const presets = ref<PresetUser[]>([]);
const loading = ref(true);
const streamerStore = useStreamerStore();
@@ -27,6 +28,7 @@ onMounted(async () => {
const defaultPreset = presets.value.find((p) => p.isDefault);
if (defaultPreset) {
selectedValue.value = defaultPreset.presetId;
modelValue.value = defaultPreset.presetId;
// Load the default preset's ice servers
loadPresetIceServers(defaultPreset.presetId);
}
@@ -60,9 +62,19 @@ watch(selectedValue, (newValue) => {
if (newValue === "create-new") {
router.push("/presets/new");
selectedValue.value = "";
modelValue.value = "";
} else if (newValue) {
modelValue.value = newValue;
// Load ice servers for the selected preset
loadPresetIceServers(newValue);
} else {
modelValue.value = "";
}
});
watch(modelValue, (newValue) => {
if (newValue !== selectedValue.value) {
selectedValue.value = newValue;
}
});
</script>

View File

@@ -24,6 +24,28 @@ export interface VenmicLinkOptions {
only_default_speakers?: boolean;
}
export interface StreamingClosePromptOptions {
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
}
export type UpdateStatus =
| "checking"
| "available"
| "not-available"
| "download-progress"
| "downloaded"
| "error";
export interface UpdateStatusPayload {
status: UpdateStatus;
version?: string;
percent?: number;
message?: string;
}
interface HeliumElectronAPI {
isElectron: boolean;
getPlatform: () => Promise<PlatformInfo>;
@@ -37,6 +59,13 @@ interface HeliumElectronAPI {
venmicUnlink: () => Promise<boolean>;
checkScreenPermission: () => Promise<string>;
openScreenPermissionSettings: () => Promise<boolean>;
setStreamingActive: (
active: boolean,
promptOptions?: StreamingClosePromptOptions,
) => Promise<boolean>;
checkForUpdates: () => Promise<boolean>;
installUpdate: () => Promise<boolean>;
onUpdateStatus: (callback: (payload: UpdateStatusPayload) => void) => () => void;
}
declare global {
@@ -235,6 +264,50 @@ export function useElectron() {
}
};
const setStreamingActive = async (
active: boolean,
promptOptions?: StreamingClosePromptOptions,
): Promise<boolean> => {
if (!checkElectron()) return false;
try {
return await window.heliumElectron!.setStreamingActive(
active,
promptOptions,
);
} catch (error) {
console.error("[useElectron] Failed to set streaming status:", error);
return false;
}
};
const checkForUpdates = async (): Promise<boolean> => {
if (!checkElectron()) return false;
try {
return await window.heliumElectron!.checkForUpdates();
} catch (error) {
console.error("[useElectron] Failed to check for updates:", error);
return false;
}
};
const installUpdate = async (): Promise<boolean> => {
if (!checkElectron()) return false;
try {
return await window.heliumElectron!.installUpdate();
} catch (error) {
console.error("[useElectron] Failed to install update:", error);
return false;
}
};
const onUpdateStatus = (callback: (payload: UpdateStatusPayload) => void): (() => void) => {
if (!checkElectron()) return () => {};
return window.heliumElectron!.onUpdateStatus(callback);
};
onMounted(() => {
checkElectron();
if (isElectron.value) {
@@ -271,6 +344,10 @@ export function useElectron() {
unlinkVenmicAudio,
getScreenPermissionStatus,
openScreenPermissionSettings,
setStreamingActive,
checkForUpdates,
installUpdate,
onUpdateStatus,
startScreenShareWithAudio,
stopScreenShare,

View File

@@ -2,8 +2,9 @@
import SignInDialog from "~/components/app/SignInDialog.vue";
import ThemeDropdown from "~/components/ui/ThemeDropdown.vue";
import LanguageSwitcher from "~/components/app/LanguageSwitcher.vue";
import { useElectron } from "~/composables/useElectron";
import { useElectron, type UpdateStatusPayload } from "~/composables/useElectron";
import "vue-sonner/style.css";
import { toast } from "vue-sonner";
import { Toaster } from "@/components/ui/sonner";
import {
Sheet,
@@ -17,7 +18,15 @@ import LogoSvg from "~/assets/logo.svg?component";
const { t } = useI18n();
const mobileMenuOpen = ref(false);
const { isElectron, platformInfo, getPlatformInfo } = useElectron();
const {
isElectron,
platformInfo,
getPlatformInfo,
installUpdate,
onUpdateStatus,
} = useElectron();
let removeUpdateStatusListener: (() => void) | undefined;
let updateProgressToastId: string | number | undefined;
const isMacElectron = computed(() => {
return isElectron.value && platformInfo.value?.isMac;
@@ -32,10 +41,74 @@ onMounted(async () => {
const navLinks = [
{ to: "/", label: "home" },
{ to: "/stream", label: "stream" },
{ to: "/about", label: "about" },
{ to: "/downloads", label: "downloads" },
{ to: "/about", label: "about", hideInElectron: true },
{ to: "/downloads", label: "downloads", hideInElectron: true },
{ to: "/presets", label: "presets", requiresAuth: true },
];
const visibleNavLinks = computed(() => {
return navLinks.filter((link) => !isElectron.value || !link.hideInElectron);
});
const showUpdateMessage = (payload: UpdateStatusPayload): void => {
if (payload.status === "checking") {
return;
}
if (payload.status === "available") {
toast.info(t("updateAvailable"), {
description: payload.version
? t("updateAvailableDescription", { version: payload.version })
: t("updateAvailableDescriptionWithoutVersion"),
});
return;
}
if (payload.status === "not-available") {
return;
}
if (payload.status === "download-progress") {
const percent = payload.percent ?? 0;
updateProgressToastId = toast.loading(t("updateDownloading"), {
id: updateProgressToastId,
description: t("updateDownloadProgress", { percent }),
});
return;
}
if (payload.status === "downloaded") {
if (updateProgressToastId) {
toast.dismiss(updateProgressToastId);
updateProgressToastId = undefined;
}
toast.success(t("updateReady"), {
description: payload.version
? t("updateReadyDescription", { version: payload.version })
: t("updateReadyDescriptionWithoutVersion"),
action: {
label: t("restartToUpdate"),
onClick: () => {
void installUpdate();
},
},
});
return;
}
toast.error(t("updateFailed"), {
description: payload.message || t("updateFailedDescription"),
});
};
onMounted(() => {
removeUpdateStatusListener = onUpdateStatus(showUpdateMessage);
});
onUnmounted(() => {
removeUpdateStatusListener?.();
});
</script>
<template>
@@ -56,7 +129,7 @@ const navLinks = [
<span class="leading-none">helium</span>
</NuxtLink>
<nav class="hidden md:flex space-x-4">
<template v-for="link in navLinks" :key="link.to">
<template v-for="link in visibleNavLinks" :key="link.to">
<ClientOnly v-if="link.requiresAuth">
<SignedIn>
<NuxtLink
@@ -114,7 +187,7 @@ const navLinks = [
<SheetTitle>{{ t("menu") || "Menu" }}</SheetTitle>
</SheetHeader>
<nav class="flex flex-col space-y-4 mt-6">
<template v-for="link in navLinks" :key="link.to">
<template v-for="link in visibleNavLinks" :key="link.to">
<ClientOnly v-if="link.requiresAuth">
<SignedIn>
<NuxtLink

View File

@@ -25,6 +25,13 @@ definePageMeta({
layout: "default",
});
interface PlatformDownload {
name: string;
icon: Component;
formats: string;
href: string;
}
interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
@@ -35,13 +42,6 @@ interface GitHubRelease {
assets: GitHubReleaseAsset[];
}
interface PlatformDownload {
name: string;
icon: Component;
formats: string;
href: string;
}
const { t } = useI18n();
const repositoryUrl = "https://github.com/SrIzan10/helium";
@@ -76,15 +76,8 @@ function findReleaseAsset(
});
}
function getReleaseAssetUrl(
patterns: readonly RegExp[],
fallbackUrl?: string,
): string {
return (
findReleaseAsset(patterns)?.browser_download_url ??
fallbackUrl ??
latestReleaseUrl.value
);
function getReleaseAssetUrl(patterns: readonly RegExp[]): string {
return findReleaseAsset(patterns)?.browser_download_url ?? latestReleaseUrl.value;
}
const desktopPlatforms = computed<PlatformDownload[]>(() => {
@@ -93,19 +86,19 @@ const desktopPlatforms = computed<PlatformDownload[]>(() => {
name: "Windows",
icon: Laptop,
formats: "NSIS, Portable",
href: getReleaseAssetUrl([/-Setup-.*\\.exe$/i, /\\.exe$/i]),
href: getReleaseAssetUrl([/-Setup-.*\.exe$/i, /\.exe$/i]),
},
{
name: "macOS",
icon: Apple,
formats: "DMG, ZIP",
href: getReleaseAssetUrl([/\\.dmg$/i, /-mac\\.zip$/i]),
href: getReleaseAssetUrl([/\.dmg$/i, /-mac\.zip$/i]),
},
{
name: "Linux",
icon: Laptop,
formats: "AppImage",
href: getReleaseAssetUrl([/\\.AppImage$/i]),
href: getReleaseAssetUrl([/\.AppImage$/i]),
},
];
});
@@ -115,7 +108,7 @@ const androidPlatform = computed<PlatformDownload>(() => {
name: "Android",
icon: Smartphone,
formats: "APK",
href: getReleaseAssetUrl([/^helium-android-.*\\.apk$/i]),
href: getReleaseAssetUrl([/^helium-android-.*\.apk$/i]),
};
});
</script>

View File

@@ -1,5 +1,10 @@
<script setup lang="ts">
const { locale } = useI18n();
const clerkLocaleVersion = useState("clerk-locale-version", () => 0);
</script>
<template>
<div class="flex h-full w-full items-center justify-center">
<SignIn routing="path" path="/sign-in" />
<SignIn :key="`${locale}-${clerkLocaleVersion}`" routing="path" path="/sign-in" />
</div>
</template>

View File

@@ -1,5 +1,10 @@
<script setup lang="ts">
const { locale } = useI18n();
const clerkLocaleVersion = useState("clerk-locale-version", () => 0);
</script>
<template>
<div class="flex h-full w-full items-center justify-center">
<SignUp routing="path" path="/sign-up" />
<SignUp :key="`${locale}-${clerkLocaleVersion}`" routing="path" path="/sign-up" />
</div>
</template>

View File

@@ -1,56 +1,189 @@
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
<div class="flex flex-wrap gap-4 items-center justify-center">
<Button v-if="!localStream" @click="startScreenShare">
{{ $t("screenshare") }}
</Button>
<Button
v-if="localStream"
@click="changeScreenShareSource"
variant="outline"
>
{{ $t("changeSource") }}
</Button>
<PresetSelect />
<div
class="min-h-[80vh] flex flex-col items-center justify-start gap-8 mt-10 px-4 pb-16"
>
<div class="text-center space-y-2">
<h1 class="text-4xl font-bold tracking-tight">{{ $t("stream") }}</h1>
</div>
<div v-if="isElectron && supportsAudioScreenShare" class="flex flex-col items-center gap-2">
<div class="flex items-center gap-2">
<Switch id="include-audio" v-model:checked="includeAudio" />
<Label for="include-audio" class="text-sm">{{ $t("includeAudio") }}</Label>
</div>
<div v-if="platformInfo?.isLinux && platformInfo?.supportsVenmic && includeAudio" class="flex flex-col items-center gap-2">
<Select v-model="selectedAudioSource">
<SelectTrigger class="w-[200px]">
<SelectValue :placeholder="$t('audioSource')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ $t("allSystemAudio") }}</SelectItem>
<SelectItem
v-for="source in audioSources"
:key="source['node.name']"
:value="source['application.name']! || source['node.name']!"
<div
class="w-full max-w-5xl grid grid-cols-1 lg:grid-cols-[380px_1fr] gap-6 items-start"
>
<div class="space-y-4">
<Card>
<CardHeader>
<CardTitle class="text-base flex items-center gap-2">
<Monitor class="size-4 text-primary" />
{{ $t("screenshare") }}
</CardTitle>
<CardDescription>
{{ $t("streamControlDescription") }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Button
v-if="!localStream"
@click="startScreenShare"
size="lg"
class="w-full gap-2"
>
{{ source['application.name'] || source['node.name'] }}
</SelectItem>
</SelectContent>
</Select>
<Button variant="ghost" size="sm" @click="refreshAudioSources">
{{ $t("refreshSources") }}
</Button>
<Monitor class="size-4" />
{{ $t("screenshare") }}
</Button>
<Button
v-if="localStream"
@click="changeScreenShareSource"
variant="outline"
size="lg"
class="w-full gap-2"
>
<RefreshCw class="size-4" />
{{ $t("changeSource") }}
</Button>
<Button
v-if="localStream"
@click="stopStreaming"
variant="destructive"
size="lg"
class="w-full gap-2"
>
<Square class="size-4" />
{{ $t("stopStream") }}
</Button>
<Separator />
<div class="space-y-2">
<Label
class="text-xs text-muted-foreground uppercase tracking-wider font-semibold"
>
{{ $t("selectAPreset") }}
</Label>
<PresetSelect v-model="selectedPresetId" />
</div>
</CardContent>
</Card>
<Card v-if="streamerStore.code" class="border-primary/30">
<CardHeader class="pb-2">
<div class="flex items-center justify-between">
<CardTitle class="text-base flex items-center gap-2">
<Share2 class="size-4 text-primary" />
{{ $t("shareCode") }}
</CardTitle>
<Badge class="text-xs bg-green-500/15 text-green-600 dark:text-green-400 border-0">
{{ $t("live") }}
</Badge>
</div>
</CardHeader>
<CardContent class="space-y-3">
<p
class="font-mono text-5xl font-bold tracking-[0.3em] text-primary text-center py-2"
>
{{ streamerStore.code }}
</p>
<Button
variant="outline"
size="sm"
class="w-full gap-2"
@click="copyCode"
>
<Copy class="size-3" />
{{ $t("copyCode") }}
</Button>
</CardContent>
</Card>
<Card v-if="isElectron && supportsAudioScreenShare">
<CardHeader>
<CardTitle class="text-base flex items-center gap-2">
<Radio class="size-4 text-primary" />
{{ $t("includeAudio") }}
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center gap-2">
<Switch id="include-audio" v-model="includeAudio" />
<Label for="include-audio" class="text-sm font-normal">
{{ $t("includeAudio") }}
</Label>
</div>
<div
v-if="platformInfo?.isLinux && platformInfo?.supportsVenmic && includeAudio"
class="space-y-3 pt-2 border-t border-border"
>
<Select v-model="selectedAudioSource">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('audioSource')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{{ $t("allSystemAudio") }}
</SelectItem>
<SelectItem
v-for="source in audioSources"
:key="source['node.name']"
:value="
source['application.name']! || source['node.name']!
"
>
{{ source['application.name'] || source['node.name'] }}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
@click="refreshAudioSources"
class="gap-2 w-full"
>
<RefreshCw class="size-3" />
{{ $t("refreshSources") }}
</Button>
</div>
</CardContent>
</Card>
</div>
<div>
<div
class="relative rounded-xl overflow-hidden border shadow-sm"
:class="[
localStream
? 'aspect-video bg-black'
: 'aspect-video bg-muted/50 flex items-center justify-center',
]"
>
<video
ref="videofeedRef"
autoplay
playsinline
muted
class="w-full h-full object-contain"
:class="{ hidden: !localStream }"
/>
<div
v-if="!localStream"
class="flex flex-col items-center gap-3 text-muted-foreground"
>
<div
class="size-16 rounded-full bg-muted flex items-center justify-center"
>
<Monitor class="size-8 opacity-40" />
</div>
<div class="text-center space-y-1">
<p class="text-sm font-medium">{{ $t("previewWaiting") }}</p>
<p class="text-xs opacity-60">{{ $t("previewWaitingDescription") }}</p>
</div>
</div>
</div>
</div>
</div>
<div v-if="isElectron" class="text-xs text-muted-foreground">
<span v-if="platformInfo?.isWindows">Windows</span>
<span v-else-if="platformInfo?.isMac">macOS</span>
<span v-else-if="platformInfo?.isLinux">Linux</span>
<span v-if="supportsAudioScreenShare" class="ml-2"> {{ $t("audioSupported") }}</span>
</div>
<p v-if="streamerStore.code" class="font-mono text-lg">{{ streamerStore.code }}</p>
<video ref="videofeedRef" autoplay playsinline muted class="max-w-full rounded-lg"></video>
</div>
</template>
@@ -60,20 +193,45 @@ import { toast } from "vue-sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import {
Copy,
Monitor,
Radio,
RefreshCw,
Share2,
Square,
} from "lucide-vue-next";
import { useStreamerStore } from "~/state/streamer";
import { useWebSocketUrl } from "~/composables/useWebSocketUrl";
import { useElectron } from "~/composables/useElectron";
import PresetSelect from "~/components/app/PresetSelect.vue";
const streamerStore = useStreamerStore();
const { t } = useI18n();
const { t, locale } = useI18n();
const videofeedRef = ref<HTMLVideoElement | null>(null);
const localStream = ref<MediaStream | null>(null);
const isCleaningUp = ref(false);
const wsUrl = useWebSocketUrl();
const includeAudio = ref(true);
const selectedAudioSource = ref("all");
const selectedPresetId = ref("");
const {
isElectron,
@@ -87,19 +245,68 @@ const {
unlinkVenmicAudio,
getScreenPermissionStatus,
openScreenPermissionSettings,
setStreamingActive,
} = useElectron();
onMounted(async () => {
await getPlatformInfo();
if (platformInfo.value?.isLinux && platformInfo.value?.supportsVenmic) {
await refreshAudioSources();
}
});
watch(localStream, async (stream) => {
await nextTick();
if (videofeedRef.value) {
videofeedRef.value.srcObject = stream;
}
});
watch([localStream, locale], async ([stream]) => {
if (!isElectron.value) return;
await setStreamingActive(
!!stream,
stream
? {
title: t("activeStreamCloseTitle"),
message: t("activeStreamCloseMessage"),
confirmLabel: t("stopStreamAndClose"),
cancelLabel: t("keepStreaming"),
}
: undefined,
);
});
async function refreshAudioSources() {
await getVenmicSources();
}
async function copyCode() {
await navigator.clipboard.writeText(streamerStore.code);
toast.success(t("codeCopied"));
}
function notifyRoomClosed() {
if (!streamerStore.code) return;
send(
JSON.stringify({
event: "close-room",
roomId: streamerStore.code,
}),
);
}
async function stopStreaming() {
if (isCleaningUp.value) return;
notifyRoomClosed();
await cleanupStreaming();
}
const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
autoReconnect: true,
heartbeat: {
@@ -189,9 +396,17 @@ const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
});
async function startScreenShare() {
if (!selectedPresetId.value) {
toast.error(t("selectPresetBeforeStreaming"));
return;
}
try {
const isLinuxWithVenmic = isElectron.value && platformInfo.value?.isLinux && platformInfo.value?.supportsVenmic;
const isLinuxWithVenmic =
isElectron.value &&
platformInfo.value?.isLinux &&
platformInfo.value?.supportsVenmic;
if (isLinuxWithVenmic && includeAudio.value) {
if (selectedAudioSource.value === "all") {
await linkAllAudio();
@@ -200,33 +415,34 @@ async function startScreenShare() {
}
}
const shouldRequestAudio = isElectron.value && includeAudio.value && supportsAudioScreenShare.value;
const shouldRequestAudio =
isElectron.value && includeAudio.value && supportsAudioScreenShare.value;
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: shouldRequestAudio ? {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
} : false,
audio: shouldRequestAudio
? {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
}
: false,
});
localStream.value = stream;
if (videofeedRef.value) {
videofeedRef.value.srcObject = stream;
}
stream.getTracks().forEach((track) => {
track.onended = () => {
console.log("Screen sharing stopped by user");
cleanupStreaming();
stopStreaming();
};
});
const videoTracks = stream.getVideoTracks();
const audioTracks = stream.getAudioTracks();
console.log(`[Helium] Stream started - Video: ${videoTracks.length}, Audio: ${audioTracks.length}`);
console.log(
`[Helium] Stream started - Video: ${videoTracks.length}, Audio: ${audioTracks.length}`,
);
send(
JSON.stringify({
@@ -242,8 +458,11 @@ async function startScreenShare() {
async function changeScreenShareSource() {
try {
const isLinuxWithVenmic = isElectron.value && platformInfo.value?.isLinux && platformInfo.value?.supportsVenmic;
const isLinuxWithVenmic =
isElectron.value &&
platformInfo.value?.isLinux &&
platformInfo.value?.supportsVenmic;
if (isLinuxWithVenmic && includeAudio.value) {
if (selectedAudioSource.value === "all") {
await linkAllAudio();
@@ -252,7 +471,8 @@ async function changeScreenShareSource() {
}
}
const shouldRequestAudio = isElectron.value && includeAudio.value && supportsAudioScreenShare.value;
const shouldRequestAudio =
isElectron.value && includeAudio.value && supportsAudioScreenShare.value;
const newStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
@@ -266,19 +486,23 @@ async function changeScreenShareSource() {
newVideoTrack!.onended = () => {
console.log("Screen sharing stopped by user");
cleanupStreaming();
stopStreaming();
};
Object.values(streamerStore.peerConnections).forEach((pc) => {
const senders = pc.getSenders();
const videoSender = senders.find((sender) => sender.track?.kind === "video");
const videoSender = senders.find(
(sender) => sender.track?.kind === "video",
);
if (videoSender) {
videoSender.replaceTrack(newVideoTrack!);
}
if (newAudioTrack) {
const audioSender = senders.find((sender) => sender.track?.kind === "audio");
const audioSender = senders.find(
(sender) => sender.track?.kind === "audio",
);
if (audioSender) {
audioSender.replaceTrack(newAudioTrack);
} else {
@@ -292,10 +516,6 @@ async function changeScreenShareSource() {
});
localStream.value = newStream;
if (videofeedRef.value) {
videofeedRef.value.srcObject = newStream;
}
} catch (error) {
console.error("Failed to change screen share source:", error);
await handleScreenShareError(error);
@@ -306,7 +526,11 @@ async function handleScreenShareError(error: unknown): Promise<void> {
const isPermissionDeniedError =
error instanceof DOMException && error.name === "NotAllowedError";
if (!isPermissionDeniedError || !isElectron.value || !platformInfo.value?.isMac) {
if (
!isPermissionDeniedError ||
!isElectron.value ||
!platformInfo.value?.isMac
) {
toast.error(t("failedToStartScreenShare"));
return;
}
@@ -329,28 +553,36 @@ async function handleScreenShareError(error: unknown): Promise<void> {
}
async function cleanupStreaming() {
if (localStream.value) {
localStream.value.getTracks().forEach((track) => {
track.stop();
if (isCleaningUp.value) return;
isCleaningUp.value = true;
try {
if (localStream.value) {
localStream.value.getTracks().forEach((track) => {
track.stop();
});
localStream.value = null;
}
if (isElectron.value && platformInfo.value?.isLinux) {
await unlinkVenmicAudio();
}
Object.values(streamerStore.peerConnections).forEach((pc) => {
pc.close();
});
localStream.value = null;
streamerStore.clearPeerConnections();
if (videofeedRef.value) {
videofeedRef.value.srcObject = null;
}
streamerStore.setCode("");
} finally {
isCleaningUp.value = false;
}
if (isElectron.value && platformInfo.value?.isLinux) {
await unlinkVenmicAudio();
}
Object.values(streamerStore.peerConnections).forEach((pc) => {
pc.close();
});
streamerStore.clearPeerConnections();
if (videofeedRef.value) {
videofeedRef.value.srcObject = null;
}
streamerStore.setCode("");
}
onBeforeUnmount(() => {

View File

@@ -1,44 +1,106 @@
import { updateClerkOptions } from "#imports";
import { esES } from "@clerk/localizations";
import { shadcn } from "@clerk/themes";
import type { Ref } from "vue";
export default defineNuxtPlugin((nuxtApp) => {
const i18n = nuxtApp.$i18n as any;
interface ClerkLocaleOptions {
localization: typeof esES | undefined;
appearance: {
theme: typeof shadcn;
};
}
if (!i18n) return;
interface I18nRuntime {
locale: Ref<string>;
}
interface MutablePublicConfig {
clerk?: Record<string, unknown>;
}
function getClerkLocaleOptions(locale: string): ClerkLocaleOptions {
return {
localization: locale === "es" ? esES : undefined,
appearance: {
theme: shadcn,
},
};
}
function getCookieValue(name: string): string | undefined {
const cookie = document.cookie
.split("; ")
.find((entry) => entry.startsWith(`${name}=`));
return cookie ? decodeURIComponent(cookie.split("=")[1] ?? "") : undefined;
}
function getInitialLocale(): string {
const cookieLocale = getCookieValue("i18n_locale");
if (cookieLocale) return cookieLocale;
if (navigator.language.toLowerCase().startsWith("es")) {
return "es";
}
return "en";
}
export default defineNuxtPlugin({
name: "clerk-locale",
enforce: "pre",
setup(nuxtApp) {
const runtimeConfig = useRuntimeConfig();
const clerkLocaleVersion = useState("clerk-locale-version", () => 0);
const publicConfig = runtimeConfig.public as MutablePublicConfig;
const initialLocale = getInitialLocale();
function updateRuntimeConfig(options: ClerkLocaleOptions): void {
publicConfig.clerk = {
...(publicConfig.clerk ?? {}),
...options,
};
}
updateRuntimeConfig(getClerkLocaleOptions(initialLocale));
function updateLocale(locale: string, attempts = 0): void {
const options = getClerkLocaleOptions(locale);
updateRuntimeConfig(options);
nuxtApp.hook("app:mounted", () => {
const checkClerk = () => {
try {
const testUpdate = () => {
updateClerkOptions({
localization: i18n.locale.value === "es" ? esES : undefined,
appearance: {
theme: shadcn,
},
});
};
testUpdate();
watch(
() => i18n.locale.value,
(newLocale) => {
const clerkLocale = newLocale === "es" ? esES : undefined;
updateClerkOptions({
localization: clerkLocale,
appearance: {
theme: shadcn,
},
});
},
);
} catch (e) {
setTimeout(checkClerk, 100);
updateClerkOptions(options);
clerkLocaleVersion.value += 1;
} catch {
if (attempts < 20) {
setTimeout(() => updateLocale(locale, attempts + 1), 100);
}
}
};
}
checkClerk();
});
nuxtApp.hook("i18n:beforeLocaleSwitch", (options) => {
updateLocale(options.newLocale);
});
nuxtApp.hook("i18n:localeSwitched", (options) => {
updateLocale(options.newLocale);
});
nuxtApp.hook("app:mounted", () => {
const i18n = nuxtApp.$i18n as I18nRuntime | undefined;
if (!i18n) {
updateLocale(initialLocale);
return;
}
watch(
() => i18n.locale.value,
(locale) => {
updateLocale(locale);
},
{ immediate: true },
);
});
},
});

View File

@@ -3,12 +3,14 @@ import {
BrowserWindow,
ipcMain,
desktopCapturer,
dialog,
session,
shell,
systemPreferences,
type IpcMainInvokeEvent,
type DesktopCapturerSource,
} from 'electron';
import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo } from 'electron-updater';
import path from 'path';
import { VenmicManager, type VenmicLinkOptions } from './venmic';
@@ -48,11 +50,96 @@ const NUXT_DEV_URL = process.env.NUXT_DEV_URL || 'http://localhost:3000';
let mainWindow: BrowserWindow | null = null;
let venmicManager: VenmicManager | null = null;
let isStreamingActive = false;
let autoUpdaterConfigured = false;
let streamingClosePrompt = {
title: 'Active stream',
message: 'A stream is still active. Closing Helium will stop it for all viewers.',
confirmLabel: 'Stop stream and close',
cancelLabel: 'Keep streaming',
};
console.log('[Helium] Platform:', process.platform);
console.log('[Helium] Wayland:', isWayland);
console.log('[Helium] XDG_SESSION_TYPE:', process.env.XDG_SESSION_TYPE);
type UpdateStatus =
| 'checking'
| 'available'
| 'not-available'
| 'download-progress'
| 'downloaded'
| 'error';
interface UpdateStatusPayload {
status: UpdateStatus;
version?: string;
percent?: number;
message?: string;
}
function getAutoUpdater(): AppUpdater {
const { autoUpdater } = electronUpdater;
return autoUpdater;
}
function sendUpdateStatus(payload: UpdateStatusPayload): void {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send('helium:update-status', payload);
}
}
}
function toUpdateStatus(status: UpdateStatus, info?: UpdateInfo): UpdateStatusPayload {
return {
status,
version: info?.version,
};
}
function setupAutoUpdater(): void {
if (isDev || !app.isPackaged || autoUpdaterConfigured) return;
autoUpdaterConfigured = true;
const autoUpdater = getAutoUpdater();
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('checking-for-update', () => {
sendUpdateStatus({ status: 'checking' });
});
autoUpdater.on('update-available', (info: UpdateInfo) => {
sendUpdateStatus(toUpdateStatus('available', info));
});
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
sendUpdateStatus(toUpdateStatus('not-available', info));
});
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
sendUpdateStatus({
status: 'download-progress',
percent: Math.round(progress.percent),
});
});
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
sendUpdateStatus(toUpdateStatus('downloaded', info));
});
autoUpdater.on('error', (error: Error) => {
console.error('[Helium] Auto updater error:', error);
sendUpdateStatus({ status: 'error', message: error.message });
});
void autoUpdater.checkForUpdatesAndNotify().catch((error: unknown) => {
console.error('[Helium] Failed to check for updates:', error);
});
}
if (isLinux) {
try {
venmicManager = new VenmicManager();
@@ -89,6 +176,27 @@ function createWindow(): void {
mainWindow.loadURL(prodUrl);
}
mainWindow.on('close', (event) => {
if (!isStreamingActive || !mainWindow) return;
const choice = dialog.showMessageBoxSync(mainWindow, {
type: 'warning',
title: streamingClosePrompt.title,
message: streamingClosePrompt.message,
buttons: [streamingClosePrompt.cancelLabel, streamingClosePrompt.confirmLabel],
defaultId: 0,
cancelId: 0,
noLink: true,
});
if (choice === 0) {
event.preventDefault();
return;
}
isStreamingActive = false;
});
mainWindow.on('closed', () => {
mainWindow = null;
});
@@ -96,6 +204,10 @@ function createWindow(): void {
mainWindow.on('page-title-updated', (e) => {
e.preventDefault();
});
mainWindow.webContents.once('did-finish-load', () => {
setupAutoUpdater();
});
}
function setupDisplayMediaHandler(): void {
@@ -152,7 +264,7 @@ ipcMain.handle('helium:get-platform', () => {
isWindows,
isWayland,
isElectron: true,
supportsLoopbackAudio: isWindows || isMac,
supportsLoopbackAudio: isWindows || isMac || isLinux,
supportsVenmic: isLinux && (venmicManager?.isAvailable() ?? false),
};
});
@@ -223,6 +335,39 @@ ipcMain.handle('helium:open-screen-permission-settings', async () => {
}
});
ipcMain.handle(
'helium:set-streaming-active',
(_event: IpcMainInvokeEvent, active: boolean, promptOptions?: typeof streamingClosePrompt) => {
isStreamingActive = active;
if (promptOptions) {
streamingClosePrompt = promptOptions;
}
return true;
},
);
ipcMain.handle('helium:check-for-updates', async () => {
if (isDev || !app.isPackaged) return false;
try {
await getAutoUpdater().checkForUpdatesAndNotify();
return true;
} catch (error) {
console.error('[Helium] Manual update check failed:', error);
return false;
}
});
ipcMain.handle('helium:install-update', () => {
if (isDev || !app.isPackaged) return false;
isStreamingActive = false;
getAutoUpdater().quitAndInstall(false, true);
return true;
});
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {

View File

@@ -35,6 +35,28 @@ export interface VenmicLinkOptions {
only_default_speakers?: boolean;
}
export interface StreamingClosePromptOptions {
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
}
export type UpdateStatus =
| 'checking'
| 'available'
| 'not-available'
| 'download-progress'
| 'downloaded'
| 'error';
export interface UpdateStatusPayload {
status: UpdateStatus;
version?: string;
percent?: number;
message?: string;
}
const heliumElectronAPI = {
isElectron: true as const,
getPlatform: (): Promise<PlatformInfo> => ipcRenderer.invoke('helium:get-platform'),
@@ -59,6 +81,20 @@ const heliumElectronAPI = {
checkScreenPermission: (): Promise<string> => ipcRenderer.invoke('helium:check-screen-permission'),
openScreenPermissionSettings: (): Promise<boolean> =>
ipcRenderer.invoke('helium:open-screen-permission-settings'),
setStreamingActive: (
active: boolean,
promptOptions?: StreamingClosePromptOptions,
): Promise<boolean> => ipcRenderer.invoke('helium:set-streaming-active', active, promptOptions),
checkForUpdates: (): Promise<boolean> => ipcRenderer.invoke('helium:check-for-updates'),
installUpdate: (): Promise<boolean> => ipcRenderer.invoke('helium:install-update'),
onUpdateStatus: (callback: (payload: UpdateStatusPayload) => void): (() => void) => {
const listener = (_event: IpcRendererEvent, payload: UpdateStatusPayload): void => {
callback(payload);
};
ipcRenderer.on('helium:update-status', listener);
return () => ipcRenderer.removeListener('helium:update-status', listener);
},
};
contextBridge.exposeInMainWorld('heliumElectron', heliumElectronAPI);

View File

@@ -1,12 +1,16 @@
{
"welcome": "Welcome",
"language": "Language",
"selectLanguage": "Select language",
"home": "Home",
"about": "About",
"contact": "Contact",
"downloads": "Downloads",
"stream": "Stream",
"streamControlDescription": "Choose a source, tune your connection preset, then share the room code.",
"shareCode": "Stream code",
"copyCode": "Copy code",
"codeCopied": "Code copied to clipboard",
"live": "Live",
"previewWaiting": "Preview waiting",
"previewWaitingDescription": "Your selected screen will appear here once sharing starts.",
"presets": "Presets",
"effortlessScreensharing": "effortless screensharing powered by webrtc",
"hostInstead": "stream instead?",
@@ -18,6 +22,7 @@
"screenshare": "Screenshare",
"loadingPresets": "Loading presets...",
"selectAPreset": "Select a preset",
"selectPresetBeforeStreaming": "Select a preset before starting a stream.",
"noPresetsAvailable": "No presets available",
"default": "default",
"createNewPreset": "Create New Preset",
@@ -56,11 +61,15 @@
"presetSharingDisabled": "The preset owner has not enabled sharing for this preset.",
"goBack": "Go Back",
"changeSource": "Change Source",
"stopStream": "Stop Stream",
"activeStreamCloseTitle": "Active stream",
"activeStreamCloseMessage": "A stream is still active. Closing Helium will stop it for all viewers.",
"stopStreamAndClose": "Stop stream and close",
"keepStreaming": "Keep streaming",
"includeAudio": "Include Audio",
"audioSource": "Audio Source",
"allSystemAudio": "All System Audio",
"refreshSources": "Refresh Sources",
"audioSupported": "Audio Supported",
"failedToStartScreenShare": "Failed to start screen share.",
"screenRecordingPermissionRequired": "macOS blocked screen capture. Allow Helium in System Settings > Privacy & Security > Screen Recording, then restart Helium.",
"screenRecordingPermissionRequiredNoShortcut": "macOS blocked screen capture. Open System Settings > Privacy & Security > Screen Recording, allow Helium, then restart Helium.",
@@ -70,11 +79,19 @@
"desktopAppDescription": "The full-featured Electron app with system audio support.",
"androidApp": "Android App",
"androidAppDescription": "Stream directly from your Android device.",
"desktopAppNote": "Includes advanced features like system audio capture, venmic support on Linux, and native screen recording permissions.",
"androidAppNote": "Install the APK directly on your Android device. Make sure to allow installation from unknown sources if prompted.",
"desktopAppNote": "Includes advanced features like system audio capture and native screen recording permissions.",
"androidAppNote": "Install the APK directly on your Android device.",
"downloadFromGitHub": "Download from GitHub",
"viewSourceCode": "View Source Code",
"preferTheBrowser": "Prefer the browser?",
"browserVersionDescription": "The web version works great too. No installation required — just open the page and start streaming or watching.",
"useWebVersion": "Use Web Version"
"updateAvailable": "Update available",
"updateAvailableDescription": "Helium {version} is downloading in the background.",
"updateAvailableDescriptionWithoutVersion": "A new Helium update is downloading in the background.",
"updateDownloading": "Downloading update",
"updateDownloadProgress": "{percent}% downloaded",
"updateReady": "Update ready",
"updateReadyDescription": "Helium {version} is ready. Restart to install it now.",
"updateReadyDescriptionWithoutVersion": "A Helium update is ready. Restart to install it now.",
"restartToUpdate": "Restart",
"updateFailed": "Update failed",
"updateFailedDescription": "Helium could not check for or download the update."
}

View File

@@ -1,12 +1,16 @@
{
"welcome": "Bienvenido",
"language": "Idioma",
"selectLanguage": "Seleccionar idioma",
"home": "Inicio",
"about": "Acerca de",
"contact": "Contacto",
"downloads": "Descargas",
"stream": "Transmisión",
"streamControlDescription": "Elige una fuente, ajusta el preset de conexión y comparte el código de la sala.",
"shareCode": "Código de la emisión",
"copyCode": "Copiar código",
"codeCopied": "Código copiado al portapapeles",
"live": "En vivo",
"previewWaiting": "Vista previa en espera",
"previewWaitingDescription": "La pantalla seleccionada aparecerá aquí cuando empieces a compartir.",
"presets": "Ajustes predefinidos",
"effortlessScreensharing": "comparte pantalla sin complicaciones",
"hostInstead": "¿prefieres transmitir pantalla?",
@@ -18,6 +22,7 @@
"screenshare": "Compartir pantalla",
"loadingPresets": "Cargando ajustes...",
"selectAPreset": "Seleccionar un ajuste",
"selectPresetBeforeStreaming": "Selecciona un ajuste antes de iniciar una transmisión.",
"noPresetsAvailable": "No hay ajustes disponibles",
"default": "predeterminado",
"createNewPreset": "Crear nuevo ajuste",
@@ -56,11 +61,15 @@
"presetSharingDisabled": "El propietario del ajuste no ha habilitado el uso compartido para este ajuste.",
"goBack": "Volver",
"changeSource": "Cambiar fuente",
"stopStream": "Detener transmisión",
"activeStreamCloseTitle": "Transmisión activa",
"activeStreamCloseMessage": "Hay una transmisión activa. Cerrar Helium la detendrá para todos los espectadores.",
"stopStreamAndClose": "Detener transmisión y cerrar",
"keepStreaming": "Seguir transmitiendo",
"includeAudio": "Incluir audio",
"audioSource": "Fuente de audio",
"allSystemAudio": "Todo el audio del sistema",
"refreshSources": "Actualizar fuentes",
"audioSupported": "Audio soportado",
"failedToStartScreenShare": "No se pudo iniciar el uso compartido de pantalla.",
"screenRecordingPermissionRequired": "macOS bloqueó la captura de pantalla. Permite Helium en Configuración del Sistema > Privacidad y seguridad > Grabación de pantalla y luego reinicia Helium.",
"screenRecordingPermissionRequiredNoShortcut": "macOS bloqueó la captura de pantalla. Abre Configuración del Sistema > Privacidad y seguridad > Grabación de pantalla, permite Helium y luego reinicia Helium.",
@@ -70,11 +79,19 @@
"desktopAppDescription": "La aplicación Electron completa con soporte de audio del sistema.",
"androidApp": "Aplicación Android",
"androidAppDescription": "Transmite directamente desde tu dispositivo Android.",
"desktopAppNote": "Incluye funciones avanzadas como captura de audio del sistema, soporte de venmic en Linux y permisos de grabación de pantalla nativos.",
"androidAppNote": "Instala el APK directamente en tu dispositivo Android. Asegúrate de permitir la instalación desde fuentes desconocidas si se te solicita.",
"desktopAppNote": "Incluye funciones avanzadas como captura de audio del sistema y permisos de grabación de pantalla nativos.",
"androidAppNote": "Instala el APK directamente en tu dispositivo Android.",
"downloadFromGitHub": "Descargar desde GitHub",
"viewSourceCode": "Ver Código Fuente",
"preferTheBrowser": "¿Prefieres el navegador?",
"browserVersionDescription": "La versión web también funciona muy bien. No requiere instalación: simplemente abre la página y empieza a transmitir o ver.",
"useWebVersion": "Usar Versión Web"
"updateAvailable": "Actualización disponible",
"updateAvailableDescription": "Helium {version} se está descargando en segundo plano.",
"updateAvailableDescriptionWithoutVersion": "Una nueva actualización de Helium se está descargando en segundo plano.",
"updateDownloading": "Descargando actualización",
"updateDownloadProgress": "{percent}% descargado",
"updateReady": "Actualización lista",
"updateReadyDescription": "Helium {version} está listo. Reinicia para instalarlo ahora.",
"updateReadyDescriptionWithoutVersion": "Una actualización de Helium está lista. Reinicia para instalarla ahora.",
"restartToUpdate": "Reiniciar",
"updateFailed": "Error al actualizar",
"updateFailedDescription": "Helium no pudo buscar o descargar la actualización."
}

View File

@@ -1,6 +1,6 @@
{
"name": "helium",
"version": "0.1.1",
"version": "0.2.2",
"author": {
"email": "helium@srizan.dev",
"name": "eth0 software",
@@ -8,6 +8,7 @@
},
"type": "module",
"private": true,
"packageManager": "pnpm@10.33.0",
"main": "./electron/dist/main/index.js",
"scripts": {
"build": "nuxt build",
@@ -45,6 +46,7 @@
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"electron-updater": "^6.8.9",
"lucide-vue-next": "^0.548.0",
"marked": "^17.0.1",
"monaco-editor": "^0.55.1",

108
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3)
electron-updater:
specifier: ^6.8.9
version: 6.8.9
lucide-vue-next:
specifier: ^0.548.0
version: 0.548.0(vue@3.5.26(typescript@5.9.3))
@@ -2028,36 +2031,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-minify/binding-linux-arm64-musl@0.102.0':
resolution: {integrity: sha512-DyH/t/zSZHuX4Nn239oBteeMC4OP7B13EyXWX18Qg8aJoZ+lZo90WPGOvhP04zII33jJ7di+vrtAUhsX64lp+A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-minify/binding-linux-riscv64-gnu@0.102.0':
resolution: {integrity: sha512-CMvzrmOg+Gs44E7TRK/IgrHYp+wwVJxVV8niUrDR2b3SsrCO3NQz5LI+7bM1qDbWnuu5Cl1aiitoMfjRY61dSg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-minify/binding-linux-s390x-gnu@0.102.0':
resolution: {integrity: sha512-tZWr6j2s0ddm9MTfWTI3myaAArg9GDy4UgvpF00kMQAjLcGUNhEEQbB9Bd9KtCvDQzaan8HQs0GVWUp+DWrymw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-minify/binding-linux-x64-gnu@0.102.0':
resolution: {integrity: sha512-0YEKmAIun1bS+Iy5Shx6WOTSj3GuilVuctJjc5/vP8/EMTZ/RI8j0eq0Mu3UFPoT/bMULL3MBXuHuEIXmq7Ddg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-minify/binding-linux-x64-musl@0.102.0':
resolution: {integrity: sha512-Ew4QDpEsXoV+pG5+bJpheEy3GH436GBe6ASPB0X27Hh9cQ2gb1NVZ7cY7xJj68+fizwS/PtT8GHoG3uxyH17Pg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-minify/binding-openharmony-arm64@0.102.0':
resolution: {integrity: sha512-wYPXS8IOu/sXiP3CGHJNPzZo4hfPAwJKevcFH2syvU2zyqUxym7hx6smfcK/mgJBiX7VchwArdGRwrEQKcBSaQ==}
@@ -2189,108 +2198,126 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-gnu@0.93.0':
resolution: {integrity: sha512-NoB7BJmwVGrcS/J5XXn362lBsIyeTqZF70rCFij3/XwQ2kcELfGMALY9AUulFYauLTY2AG4vcmctJQxn9Lj85g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-gnu@0.95.0':
resolution: {integrity: sha512-0LzebARTU0ROfD6pDK4h1pFn+09meErCZ0MA2TaW08G72+GNneEsksPufOuI+9AxVSRa+jKE3fu0wavvhZgSkg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-musl@0.102.0':
resolution: {integrity: sha512-/XWcmglH/VJ4yKAGTLRgPKSSikh3xciNxkwGiURt8dS30b+3pwc4ZZmudMu0tQ3mjSu0o7V9APZLMpbHK8Bp5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-arm64-musl@0.93.0':
resolution: {integrity: sha512-s+nraJJR9SuHsgsr42nbOBpAsaSAE6MhK7HGbz01svLJzDsk3Ylh9cbVUPLaS3gOlTq5WC6VjPBkQuInLo0hvQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-arm64-musl@0.95.0':
resolution: {integrity: sha512-Pvi1lGe/G+mJZ3hUojMP/aAHAzHA25AEtVr8/iuz7UV72t/15NOgJYr9kELMUMNjPqpr3vKUgXTFmTtAxp11Qw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-riscv64-gnu@0.102.0':
resolution: {integrity: sha512-2jtIq4nswvy6xdqv1ndWyvVlaRpS0yqomLCvvHdCFx3pFXo5Aoq4RZ39kgvFWrbAtpeYSYeAGFnwgnqjx9ftdw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-gnu@0.93.0':
resolution: {integrity: sha512-oNIQb/7HGxVNeVgtkoqNcDS1hjfxArLDuMI72V+Slp67yfBdxgvfmM2JSWE7kGR5gyiZQeTjRbG89VrRwPDtww==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-gnu@0.95.0':
resolution: {integrity: sha512-pUEVHIOVNDfhk4sTlLhn6mrNENhE4/dAwemxIfqpcSyBlYG0xYZND1F3jjR2yWY6DakXZ6VSuDbtiv1LPNlOLw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-s390x-gnu@0.102.0':
resolution: {integrity: sha512-Yp6HX/574mvYryiqj0jNvNTJqo4pdAsNP2LPBTxlDQ1cU3lPd7DUA4MQZadaeLI8+AGB2Pn50mPuPyEwFIxeFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-s390x-gnu@0.93.0':
resolution: {integrity: sha512-YyzhzAoq5WpRtAGOngpJUu+4jKagSbknORejmpeW48vu8/+XjrVZFc/1Qe4i72EsPzLorDwCxWVkU8VftpM4iA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-s390x-gnu@0.95.0':
resolution: {integrity: sha512-5+olaepHTE3J/+w7g0tr3nocvv5BKilAJnzj4L8tWBCLEZbL6olJcGVoldUO+3cgg1SO1xJywP5BuLhT0mDUDw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.102.0':
resolution: {integrity: sha512-R4b0xZpDRhoNB2XZy0kLTSYm0ZmWeKjTii9fcv1Mk3/SIGPrrglwt4U6zEtwK54Dfi4Bve5JnQYduigR/gyDzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.93.0':
resolution: {integrity: sha512-UMXsE6c0MIlvtqDe5t5K8qwC6HqNb3wmy8zKxONo42dIx0WAhVV9ydG2Xlznt1/RhD6nLLtHVaq4yWJXRjUxcg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.95.0':
resolution: {integrity: sha512-8huzHlK/N98wrnYKxIcYsK8ZGBWomQchu/Mzi6m+CtbhjWOv9DmK0jQ2fUWImtluQVpTwS0uZT06d3g7XIkJrA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-musl@0.102.0':
resolution: {integrity: sha512-xM5A+03Ti3jvWYZoqaBRS3lusvnvIQjA46Fc9aBE/MHgvKgHSkrGEluLWg/33QEwBwxupkH25Pxc1yu97oZCtg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-x64-musl@0.93.0':
resolution: {integrity: sha512-0Vd0yFUq129VW+Cpcj/gJOqub4EMN5hUWnVk8UfAvUZ+lxZBFeXbYNI5483SLwzvw5umzlMmkKpYWw5OTwYFaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-x64-musl@0.95.0':
resolution: {integrity: sha512-bWnrLfGDcx/fab0+UQnFbVFbiykof/btImbYf+cI2pU/1Egb2x+OKSmM5Qt0nEUiIpM5fgJmYXxTopybSZOKYA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-openharmony-arm64@0.102.0':
resolution: {integrity: sha512-AieLlsliblyaTFq7Iw9Nc618tgwV02JT4fQ6VIUd/3ZzbluHIHfPjIXa6Sds+04krw5TvCS8lsegtDYAyzcyhg==}
@@ -2429,72 +2456,84 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-transform/binding-linux-arm64-gnu@0.95.0':
resolution: {integrity: sha512-NLdrFuEHlmbiC1M1WESFV4luUcB/84GXi+cbnRXhgMjIW/CThRVJ989eTJy59QivkVlLcJSKTiKiKCt0O6TTlQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-transform/binding-linux-arm64-musl@0.102.0':
resolution: {integrity: sha512-I08iWABrN7zakn3wuNIBWY3hALQGsDLPQbZT1mXws7tyiQqJNGe49uS0/O50QhX3KXj+mbRGsmjVXLXGJE1CVQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-transform/binding-linux-arm64-musl@0.95.0':
resolution: {integrity: sha512-GL0ffCPW8JlFI0/jeSgCY665yDdojHxA0pbYG+k8oEHOWCYZUZK9AXL+r0oerNEWYJ8CRB+L5Yq87ZtU/YUitw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-transform/binding-linux-riscv64-gnu@0.102.0':
resolution: {integrity: sha512-9+SYW1ARAF6Oj/82ayoqKRe8SI7O1qvzs3Y0kijvhIqAaaZWcFRjI5DToyWRAbnzTtHlMcSllZLXNYdmxBjFxA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-transform/binding-linux-riscv64-gnu@0.95.0':
resolution: {integrity: sha512-tbH7LaClSmN3YFVo1UjMSe7D6gkb5f+CMIbj9i873UUZomVRmAjC4ygioObfzM+sj/tX0WoTXx5L1YOfQkHL6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-transform/binding-linux-s390x-gnu@0.102.0':
resolution: {integrity: sha512-HV9nTyQw0TTKYPu+gBhaJBioomiM9O4LcGXi+s5IylCGG6imP0/U13q/9xJnP267QFmiWWqnnSFcv0QAWCyh8A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-transform/binding-linux-s390x-gnu@0.95.0':
resolution: {integrity: sha512-8jMqiURWa0iTiPMg7BWaln89VdhhWzNlPyKM90NaFVVhBIKCr2UEhrQWdpBw/E9C8uWf/4VabBEhfPMK+0yS4w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-transform/binding-linux-x64-gnu@0.102.0':
resolution: {integrity: sha512-4wcZ08mmdFk8OjsnglyeYGu5PW3TDh87AmcMOi7tZJ3cpJjfzwDfY27KTEUx6G880OpjAiF36OFSPwdKTKgp2g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-transform/binding-linux-x64-gnu@0.95.0':
resolution: {integrity: sha512-D5ULJ2uWipsTgfvHIvqmnGkCtB3Fyt2ZN7APRjVO+wLr+HtmnaWddKsLdrRWX/m/6nQ2xQdoQekdJrokYK9LtQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-transform/binding-linux-x64-musl@0.102.0':
resolution: {integrity: sha512-rUHZSZBw0FUnUgOhL/Rs7xJz9KjH2eFur/0df6Lwq/isgJc/ggtBtFoZ+y4Fb8ON87a3Y2gS2LT7SEctX0XdPQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-transform/binding-linux-x64-musl@0.95.0':
resolution: {integrity: sha512-DmCGU+FzRezES5wVAGVimZGzYIjMOapXbWpxuz8M8p3nMrfdBEQ5/tpwBp2vRlIohhABy4vhHJByl4c64ENCGQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-transform/binding-openharmony-arm64@0.102.0':
resolution: {integrity: sha512-98y4tccTQ/pA+r2KA/MEJIZ7J8TNTJ4aCT4rX8kWK4pGOko2YsfY3Ru9DVHlLDwmVj7wP8Z4JNxdBrAXRvK+0g==}
@@ -2565,36 +2604,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-wasm@2.5.1':
resolution: {integrity: sha512-RJxlQQLkaMMIuWRozy+z2vEqbaQlCuaCgVZIUCzQLYggY22LZbP5Y1+ia+FD724Ids9e+XIyOLXLrLgQSHIthw==}
@@ -2866,56 +2911,67 @@ packages:
resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.5':
resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.5':
resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.5':
resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.5':
resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.5':
resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.5':
resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.5':
resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.5':
resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.5':
resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.5':
resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.5':
resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
@@ -3154,24 +3210,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.16':
resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.16':
resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.16':
resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.16':
resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==}
@@ -4032,6 +4092,10 @@ packages:
resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==}
engines: {node: '>=12.0.0'}
builder-util-runtime@9.7.0:
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
engines: {node: '>=12.0.0'}
builder-util@25.1.7:
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
@@ -4802,6 +4866,9 @@ packages:
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
electron-updater@6.8.9:
resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}
electron@33.4.11:
resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==}
engines: {node: '>= 12.20.55'}
@@ -5973,24 +6040,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -6040,12 +6111,19 @@ packages:
lodash.difference@4.5.0:
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
lodash.escaperegexp@4.1.2:
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
lodash.flatten@4.4.0:
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
@@ -7871,6 +7949,9 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tiny-typed-emitter@2.1.0:
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
tinyexec@1.0.1:
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
@@ -8205,6 +8286,7 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validate-npm-package-name@5.0.1:
@@ -13275,6 +13357,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
builder-util-runtime@9.7.0:
dependencies:
debug: 4.4.3
sax: 1.4.1
transitivePeerDependencies:
- supports-color
builder-util@25.1.7:
dependencies:
7zip-bin: 5.2.0
@@ -14057,6 +14146,19 @@ snapshots:
electron-to-chromium@1.5.267: {}
electron-updater@6.8.9:
dependencies:
builder-util-runtime: 9.7.0
fs-extra: 10.1.0
js-yaml: 4.1.1
lazy-val: 1.0.5
lodash.escaperegexp: 4.1.2
lodash.isequal: 4.5.0
semver: 7.7.3
tiny-typed-emitter: 2.1.0
transitivePeerDependencies:
- supports-color
electron@33.4.11:
dependencies:
'@electron/get': 2.0.3
@@ -15460,10 +15562,14 @@ snapshots:
lodash.difference@4.5.0: {}
lodash.escaperegexp@4.1.2: {}
lodash.flatten@4.4.0: {}
lodash.isarguments@3.1.0: {}
lodash.isequal@4.5.0: {}
lodash.isplainobject@4.0.6: {}
lodash.memoize@4.1.2: {}
@@ -17770,6 +17876,8 @@ snapshots:
tiny-invariant@1.3.3: {}
tiny-typed-emitter@2.1.0: {}
tinyexec@1.0.1: {}
tinyexec@1.0.2: {}

View File

@@ -54,6 +54,20 @@ async function deleteRoom(roomId: string) {
await db.delete(schema.rooms).where(eq(schema.rooms.id, roomId));
}
async function closeRoom(roomId: string, broadcasterId: string) {
const room = await getRoom(roomId);
if (!room || room.broadcaster !== broadcasterId) return;
room.viewers.forEach((viewerId: string) => {
const viewer = activePeers.get(viewerId);
if (viewer) {
viewer.send(JSON.stringify({ event: 'room-closed' }));
}
});
await deleteRoom(roomId);
}
async function addViewerToRoom(roomId: string, viewerId: string) {
await db.insert(schema.roomViewers).values({
roomId,
@@ -104,6 +118,9 @@ export default defineWebSocketHandler({
await createRoom(roomId, peer.id);
peer.send(JSON.stringify({ event: 'room-created', roomId }));
}
if (msg.event === 'close-room') {
await closeRoom(msg.roomId, peer.id);
}
if (msg.event === 'join-room') {
const room = await getRoom(msg.roomId);
if (room) {