Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ on:
branches: [main]

jobs:
desktop-media:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: yarn
- run: COREPACK_ENABLE_STRICT=0 npx -y [email protected] install --frozen-lockfile
shell: bash
- if: runner.os == 'Linux'
run: |
sudo chown root:root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- if: runner.os == 'Linux'
run: xvfb-run -a yarn test:desktop-video-decoder
- if: runner.os != 'Linux'
run: yarn test:desktop-video-decoder
verify:
runs-on: ubuntu-latest
env:
Expand Down Expand Up @@ -107,6 +130,11 @@ jobs:
# A member changing group moves their row to another section, which remounts
# it. The open hover card has to survive that.
- run: yarn test:member-card-state
- run: yarn test:banner-crop
- run: yarn test:card-banner-store
- run: yarn test:card-emoji-groups
- run: yarn test:card-editor-flow
- run: yarn test:media-processing
- run: yarn test:sidebar-tree
- run: yarn test:hide-muted
- run: yarn test:settings-index
Expand Down
Binary file added e2e/fixtures/clip.mp4
Binary file not shown.
39 changes: 39 additions & 0 deletions e2e/tests/card-editor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { fixture, membersPanel } from "../support/app";
import { expect, test } from "../support/fixtures";

test("member card opens its editor directly and Cancel restores the saved preview without uploading", async ({ owner }) => {
const page = owner.page;
const uploads: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST" && request.url().includes("/api/uploads/banner")) uploads.push(request.url());
});
await membersPanel(page).locator(".member-row").filter({ hasText: owner.name }).click();
await page.getByRole("button", { name: "Edit my card", exact: true }).click();
const editor = page.getByRole("dialog", { name: "Edit my card", exact: true });
await expect(editor).toBeVisible();
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
const readStyle = (element: Element) => Object.fromEntries(Array.from((element as HTMLElement).style).sort()
.map((property) => [property, (element as HTMLElement).style.getPropertyValue(property)]));
const savedStyle = await editor.locator(".gmc-frame").evaluate(readStyle);
await editor.getByRole("button", { name: "Surprise me" }).click();
await expect.poll(() => editor.locator(".gmc-frame").evaluate(readStyle)).not.toEqual(savedStyle);
await editor.getByRole("tab", { name: "About you" }).click();
await editor.locator('input[type="file"]').setInputFiles(fixture("gradient.png"));
const crop = page.getByRole("dialog", { name: "Position banner" });
await expect(crop).toBeVisible();
await crop.getByRole("button", { name: "Use crop" }).click();
await expect(crop).toBeHidden();
await expect(editor.locator(".gmc-frame")).toHaveAttribute("style", /--img:\s*url\(["']?blob:/);
expect(uploads).toEqual([]);
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
const confirm = page.getByRole("alertdialog", { name: "Discard card changes?" });
await expect(confirm).toBeVisible();
await confirm.getByRole("button", { name: "Keep editing" }).click();
await expect(editor).toBeVisible();
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
await confirm.getByRole("button", { name: "Discard changes" }).click();
await expect(editor).toBeHidden();
await expect.poll(() => settings.locator(".gmc-frame").evaluate(readStyle)).toEqual(savedStyle);
await expect(settings.locator(".gmc-banner.img")).toHaveCount(0);
expect(uploads).toEqual([]);
});
40 changes: 40 additions & 0 deletions e2e/tests/emoji-picker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";

import { accessTokenOf } from "../support/admin";
import { channelComposer, fixture, membersPanel } from "../support/app";
import { expect, test } from "../support/fixtures";

test("chat and card pickers put this server's custom emoji first and keep Unicode searchable", async ({ owner, gryt, request }) => {
const page = owner.page;
const token = await accessTokenOf(page, gryt.server.host);
const name = "card_test_custom";
const upload = await request.post(`${gryt.server.httpBase}/api/emojis`, {
headers: { Authorization: `Bearer ${token}` },
multipart: { name, file: { name: "custom.png", mimeType: "image/png", buffer: readFileSync(fixture("gradient.png")) } },
});
expect(upload.status(), await upload.text()).toBe(201);
await page.getByRole("button", { name: "Insert emoji", exact: true }).click();
const chatPicker = page.locator(".gryt-emoji-picker");
await expect(chatPicker.getByRole("button", { name: "This server", exact: true })).toBeVisible();
await expect(chatPicker.locator("[data-emoji-category]").first()).toHaveAttribute("aria-label", "This server");
await chatPicker.getByRole("button", { name: `:${name}:`, exact: true }).click();
await expect(channelComposer(page).getByRole("img", { name: `:${name}:`, exact: true })).toBeVisible();
await expect(chatPicker).toBeHidden();
await page.getByRole("button", { name: "Insert emoji", exact: true }).click();
await chatPicker.getByPlaceholder("Search emojis...").fill("thumbsup");
await chatPicker.getByTitle(":thumbsup:", { exact: true }).click();
await expect(channelComposer(page)).toContainText("👍");

await membersPanel(page).locator(".member-row").filter({ hasText: owner.name }).click();
await page.getByRole("button", { name: "Edit my card", exact: true }).click();
const editor = page.getByRole("dialog", { name: "Edit my card", exact: true });
await editor.getByRole("tab", { name: "Pattern", exact: true }).click();
await editor.getByRole("button", { name: "An emoji", exact: true }).click();
await editor.getByRole("button", { name: /^Choose emoji/ }).click();
const cardPicker = page.locator(".gryt-emoji-picker");
await expect(cardPicker.locator("[data-emoji-category]").first()).toHaveAttribute("aria-label", "This server");
await cardPicker.getByRole("button", { name: `:${name}:`, exact: true }).click();
await expect(editor.getByRole("button", { name: /^Choose emoji/ })).toContainText("server emoji");
await expect.poll(() => editor.locator(".gmc-frame").evaluate((element) =>
(element as HTMLElement).style.getPropertyValue("--pat-img"))).toMatch(/%3Cimage|<image/);
});
82 changes: 82 additions & 0 deletions electron/desktopVideoDecoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { ChildProcess } from "node:child_process";

import { BrowserWindow, session } from "electron";

const MAX_INPUT_LENGTH = Math.ceil(64 * 1024 * 1024 / 3) * 4;

export function attachDesktopVideoDecoder(worker: ChildProcess): void {
const windows = new Set<BrowserWindow>();
const decode = async (message: unknown) => {
if (!message || typeof message !== "object") return;
const request = message as { type?: unknown; id?: unknown; input?: unknown };
if (request.type !== "gryt:video-frame" || typeof request.id !== "string" || !/^[a-f0-9-]{36}$/.test(request.id)) return;
const reply = (result: { frame?: string; error?: string }) => {
if (worker.connected) worker.send({ type: "gryt:video-frame-result", id: request.id, ...result }, () => undefined);
};
if (windows.size >= 2 || typeof request.input !== "string" || request.input.length > MAX_INPUT_LENGTH
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(request.input)) {
reply({ error: "Video exceeds desktop decoder limits" });
return;
}
const isolated = session.fromPartition("gryt-video-decoder", { cache: false });
isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false));
isolated.setPermissionCheckHandler(() => false);
isolated.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !/^(data:|blob:)/.test(details.url) }));
isolated.on("will-download", (event) => event.preventDefault());
const window = new BrowserWindow({ show: false, webPreferences: {
session: isolated, sandbox: true, nodeIntegration: false, contextIsolation: true,
webSecurity: true, backgroundThrottling: false,
} });
windows.add(window);
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event) => event.preventDefault());
const timeout = setTimeout(() => { if (!window.isDestroyed()) window.destroy(); }, 12000);
try {
await window.loadURL("data:text/html,<meta http-equiv=Content-Security-Policy content=\"default-src 'none'; media-src blob:; script-src 'unsafe-eval'\">");
const frame: unknown = await window.webContents.executeJavaScript(`(async () => {
const bytes = Uint8Array.from(atob(${JSON.stringify(request.input)}), c => c.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' }));
const video = document.createElement('video');
video.muted = true;
video.preload = 'auto';
try {
await new Promise((resolve, reject) => {
video.onloadeddata = resolve;
video.onerror = () => reject(new Error('Video could not be decoded'));
video.src = url;
});
if (!video.videoWidth || !video.videoHeight || video.videoWidth * video.videoHeight > 100000000) throw new Error('Video exceeds pixel limit');
const seek = Math.min(1, Number.isFinite(video.duration) ? video.duration / 2 : 0);
if (seek > 0) await new Promise((resolve, reject) => {
video.onseeked = resolve;
video.onerror = () => reject(new Error('Video frame could not be decoded'));
video.currentTime = seek;
});
const canvas = document.createElement('canvas');
canvas.width = Math.min(320, video.videoWidth);
canvas.height = Math.max(1, Math.round(video.videoHeight * canvas.width / video.videoWidth));
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
} finally {
video.removeAttribute('src');
video.load();
URL.revokeObjectURL(url);
}
})()`);
if (typeof frame !== "string" || frame.length > 4 * 1024 * 1024) throw new Error("Invalid video frame");
reply({ frame });
} catch {
reply({ error: "Video could not be decoded in the desktop sandbox" });
} finally {
clearTimeout(timeout);
windows.delete(window);
if (!window.isDestroyed()) window.destroy();
await isolated.clearStorageData().catch(() => undefined);
}
};
worker.on("message", (message) => { void decode(message); });
worker.once("exit", () => {
for (const window of windows) if (!window.isDestroyed()) window.destroy();
windows.clear();
});
}
3 changes: 3 additions & 0 deletions electron/embeddedServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createServer } from "net";
import { join } from "path";
import { extract } from "tar";

import { attachDesktopVideoDecoder } from "./desktopVideoDecoder";
import { pruneEmbeddedRuntimes } from "./embeddedRuntimeCleanup";
import {
checkPortsAvailable,
Expand Down Expand Up @@ -714,12 +715,14 @@ function spawnWorker(
// Only the server beside it reads this endpoint, and it dials 127.0.0.1.
// Last, so neither config.env nor the Electron process can widen it.
HEALTH_HOST: "127.0.0.1",
GRYT_VIDEO_DECODER: "electron-sandbox",
},
stdio: ["ignore", "pipe", "pipe", "ipc"],
cwd: getServerDir(id),
silent: true,
});

attachDesktopVideoDecoder(proc);
const onOutput = (data: Buffer) => {
const msg = data.toString().trim();
if (msg) {
Expand Down
4 changes: 4 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ import {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

if (!app.isPackaged && process.env.GRYT_DEV_USER_DATA) {
app.setPath("userData", resolve(process.env.GRYT_DEV_USER_DATA));
}

// ── Startup logging ──────────────────────────────────────────────────────

const LOG_PATH = join(app.getPath("userData"), "gryt-startup.log");
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@
"test:chat-retry": "node --disable-warning=ExperimentalWarning scripts/check-chat-retry.mjs",
"test:send-queue": "node --disable-warning=ExperimentalWarning scripts/check-send-queue.mjs",
"test:member-card-state": "node scripts/check-member-card-state.mjs",
"test:banner-crop": "node scripts/check-banner-crop.mjs",
"test:card-banner-store": "node scripts/check-card-banner-store.mjs",
"test:card-emoji-groups": "node scripts/check-card-emoji-groups.mjs",
"test:card-editor-flow": "node scripts/check-card-editor-flow.mjs",
"test:media-processing": "node scripts/check-media-processing.mjs",
"test:desktop-video-decoder": "node scripts/check-desktop-video-decoder.mjs",
"test:sidebar-tree": "node scripts/check-sidebar-tree.mjs",
"test:hide-muted": "node --disable-warning=ExperimentalWarning scripts/check-hide-muted.mjs",
"test:settings-index": "node scripts/check-settings-index.mjs",
Expand Down Expand Up @@ -219,7 +225,7 @@
"@gryt/core": "0.16.0",
"@gryt/crypto": "0.9.0",
"@gryt/owl": "^0.6.0",
"@gryt/ui": "^0.39.0",
"@gryt/ui": "^0.39.2-beta.1",
"@gryt/voice": "0.6.1",
"@mediapipe/tasks-vision": "^1.0.1",
"@noble/curves": "^2.3.0",
Expand Down
30 changes: 30 additions & 0 deletions scripts/check-banner-crop.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";

import { bannerCropGeometry, imageBytesMayAnimate } from "../src/packages/settings/src/components/bannerCrop.ts";

const wide = bannerCropGeometry(
{ width: 2000, height: 500 },
{ width: 1000, height: 400 },
1,
{ x: 999, y: 999 },
);
assert.deepEqual(wide.position, { x: 300, y: 0 });
assert.deepEqual(wide.source, { x: 0, y: 0, width: 1250, height: 500 });

const zoomed = bannerCropGeometry(
{ width: 1000, height: 400 },
{ width: 1000, height: 400 },
2,
{ x: -5000, y: 5000 },
);
assert.deepEqual(zoomed.position, { x: -500, y: 200 });
assert.deepEqual(zoomed.source, { x: 500, y: 0, width: 500, height: 200 });

const bytes = (value) => new TextEncoder().encode(value);
assert.equal(imageBytesMayAnimate("image/gif", bytes("GIF89a")), true);
assert.equal(imageBytesMayAnimate("image/webp", bytes("RIFFxxxxWEBPANIM")), true);
assert.equal(imageBytesMayAnimate("image/webp", bytes("RIFFxxxxWEBPVP8 ")), false);
assert.equal(imageBytesMayAnimate("image/png", bytes("PNG acTL")), true);
assert.equal(imageBytesMayAnimate("image/jpeg", bytes("jpeg")), false);

console.log("banner crop: ok");
43 changes: 43 additions & 0 deletions scripts/check-card-banner-store.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { File } from "node:buffer";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

import { build } from "esbuild";
import { indexedDB, IDBKeyRange } from "fake-indexeddb";

globalThis.File = File;
globalThis.indexedDB = indexedDB;
globalThis.IDBKeyRange = IDBKeyRange;

const here = dirname(fileURLToPath(import.meta.url));
const built = await build({
entryPoints: [join(here, "../src/packages/socket/src/lib/memberCard/cardBannerStore.ts")],
bundle: true,
format: "esm",
platform: "browser",
write: false,
});
const code = Buffer.from(built.outputFiles[0].contents).toString("base64");
const store = await import(`data:text/javascript;base64,${code}`);

const red = { fill: "solid", c1: "#ff0000", pattern: "dots" };
const blue = { fill: "solid", c1: "#0000ff", pattern: "weave" };
const file = new File([new Uint8Array([1, 2, 3])], "banner.webp", { type: "image/webp" });

assert.deepEqual(await store.getCardBanner(red), { found: false, file: null });
await store.setCardBanner(red, file);
const saved = await store.getCardBanner(red);
assert.equal(saved.found, true);
assert.equal(saved.file.name, "banner.webp");
assert.equal(saved.file.type, "image/webp");
assert.deepEqual([...new Uint8Array(await saved.file.arrayBuffer())], [1, 2, 3]);

await store.setCardBanner(blue, null);
assert.deepEqual(await store.getCardBanner(blue), { found: true, file: null });
await store.pruneCardBanners([blue]);
assert.deepEqual(await store.getCardBanner(red), { found: false, file: null });
await store.deleteCardBanner(blue);
assert.deepEqual(await store.getCardBanner(blue), { found: false, file: null });

console.log("card banner store: ok");
22 changes: 22 additions & 0 deletions scripts/check-card-editor-flow.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";

const settings = readFileSync("src/packages/settings/src/components/cardSettings.tsx", "utf8");
const card = readFileSync("src/packages/socket/src/components/MemberIdentityCard.tsx", "utf8");
const shared = readFileSync("src/lib/sharedLook.ts", "utf8");
const view = readFileSync("src/packages/socket/src/components/memberCard/MemberCardView.tsx", "utf8");

assert.match(card, /openSettings\("profile\/card\/edit"\)/);
assert.match(card, /cardBanner: member\.bannerFileId/);
assert.match(card, /const worn = member\.avatarWorn/);
assert.match(settings, /title="Discard card changes\?"/);
assert.match(settings, /confirmLabel="Discard changes"/);
assert.match(settings, /const discardChanges = \(\) => \{[\s\S]*setDraft\(saved\);[\s\S]*setBannerPreview\(undefined\);[\s\S]*setPendingBanner\(undefined\);/);
assert.match(settings, /takeSharedLook\("cardBanner"\)/);
assert.match(shared, /cardBanner\?: string \| null/);
assert.match(settings, /accept="[^"]*video\/mp4/);
assert.match(settings, /server_info\?\.upload_max_bytes/);
assert.match(view, /fetch\(url, \{ method: "HEAD"/);
assert.match(view, /bannerType/);

console.log("card editor flow: ok");
15 changes: 15 additions & 0 deletions scripts/check-card-emoji-groups.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";

const emojiData = readFileSync("src/packages/socket/src/utils/emojiData.ts", "utf8");
const cardGroups = readFileSync("src/packages/socket/src/components/memberCard/cardEmojiGroups.ts", "utf8");
const cardSettings = readFileSync("src/packages/settings/src/components/cardSettings.tsx", "utf8");
const cardView = readFileSync("src/packages/socket/src/components/memberCard/MemberCardView.tsx", "utf8");

assert.match(emojiData, /customEmojisByServer\.set\(serverHost, next\)/);
assert.match(emojiData, /customEmojisByServer\.get\(serverHost\)/);
assert.match(cardGroups, /getCustomEmojis\(serverHost\)/);
assert.match(cardSettings, /useCardEmojiGroups\(currentlyViewingServer\?\.host\)/);
assert.match(cardView, /useCardEmojiGroups\(currentlyViewingServer\?\.host\)/);

console.log("card emoji groups: ok");
Loading
Loading