Skip to content
Merged
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
1 change: 1 addition & 0 deletions clients/desktop/src-tauri/src/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
if (!T || !T.core) return; // running in a plain browser — no-op, the PWA behaves normally
var invoke = T.core.invoke;
window.__gcOpenGamingExternal = function (url) { return invoke("open_gaming_external", { url: url }); };
window.__gcOpenProviderAuthorization = function (url) { return invoke("open_provider_authorization", { url: url }); };
window.__gcOpenTikTokPost = function (url) { return invoke("open_tiktok_post", { url: url }); };
window.__gcEpicLibrary = {
list: function () { return invoke("epic_installed_games"); },
Expand Down
16 changes: 16 additions & 0 deletions clients/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ fn open_gaming_external(url: String) -> Result<(), String> {
open_provider_browser(parsed)
}

#[tauri::command]
fn open_provider_authorization(url: String) -> Result<(), String> {
let parsed = Url::parse(&url).map_err(|_| "Invalid authorization URL")?;
let destination = matches!((parsed.host_str(), parsed.path()),
(Some("www.epicgames.com"), "/id/authorize") |
(Some("www.tiktok.com"), "/v2/auth/authorize/"));
if parsed.scheme() != "https" || !destination || !parsed.username().is_empty()
|| parsed.password().is_some() || parsed.port().is_some() || parsed.fragment().is_some()
|| !parsed.query_pairs().any(|(k, v)| k == "response_type" && v == "code")
|| !parsed.query_pairs().any(|(k, v)| k == "state" && !v.is_empty()) {
return Err("Invalid authorization URL".into());
}
open_provider_browser(parsed)
}

#[tauri::command]
fn open_tiktok_post(url: String) -> Result<(), String> {
let parsed = Url::parse(&url).map_err(|_| "Invalid TikTok URL")?;
Expand Down Expand Up @@ -518,6 +533,7 @@ pub fn run() {
app_version,
desktop_identity,
open_gaming_external,
open_provider_authorization,
open_tiktok_post,
epic::epic_installed_games,
epic::epic_launch_game,
Expand Down
2 changes: 1 addition & 1 deletion clients/ui/src/screens/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,7 @@ export function createApp(deps: AppDeps): App {
return;
}
if (r.name === "social") {
swap("social", () => wrapInShell(createSocialScreen({ locale: i18n.locale,
swap("social", () => wrapInShell(createSocialScreen({ locale: i18n.locale, api,
onBack: () => router.navigate("/settings") }), "settings"));
return;
}
Expand Down
4 changes: 3 additions & 1 deletion clients/ui/src/screens/gamer_mode_screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface GamerModeScreenDeps {
onOpenSteam?: () => void;
onOpenFaceit?: () => void;
onOpenEpic?: () => void;
epicConnection?: HTMLElement;
onBack?: () => void;
onUnlinkSteam?: () => void;
onUnlinkFaceit?: () => void;
Expand Down Expand Up @@ -264,7 +265,8 @@ export function createGamerModeScreen(deps: GamerModeScreenDeps): GamerModeScree
card.append(rank, bar, button(linked ? copy.open : copy.link, deps.onOpenFaceit));
if (linked && deps.onUnlinkFaceit) card.append(button(copy.unlink, deps.onUnlinkFaceit));
} else {
card.append(node("p", "gc-gm-provider-hint", copy.epicHint), button(copy.store, deps.onOpenEpic));
if (deps.epicConnection) card.append(deps.epicConnection);
else card.append(node("p", "gc-gm-provider-hint", copy.epicHint), button(copy.store, deps.onOpenEpic));
}
return card;
};
Expand Down
10 changes: 9 additions & 1 deletion clients/ui/src/screens/gaming_screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createGamerModeScreen, type GamerModeScreen } from "./gamer_mode_screen
import { gamingExternalUrl, gamingProfile, validateGamingState, type GamingSettings, type GamingState } from "./gaming_model.ts";
import { el } from "../dom.ts";
import { createEpicLibrary, type EpicLibraryPort } from "./epic_library.ts";
import { createProviderConnection, type ProviderConnectionState } from "./provider_connection.ts";
import type { ApiLike } from "./api.ts";
import type { I18n } from "../i18n.ts";

Expand All @@ -27,6 +28,11 @@ export function createGamingScreen(deps: {
let busy = false;
let generation = 0;
let fresh = false;
let epicState: ProviderConnectionState | null = null;
const epicConnection = createProviderConnection({ provider: "epic", locale: i18n.locale, api,
...(deps.openExternal ? { openExternal: deps.openExternal } : {}),
onChange(next) { epicState = next; if (!destroyed) render(); },
});

const open = async (url: string, provider: "steam" | "faceit" | "epic", auth = false): Promise<void> => {
const safe = gamingExternalUrl(url, provider, auth);
Expand Down Expand Up @@ -87,7 +93,8 @@ export function createGamingScreen(deps: {
const steam = state?.profile.steam;
const faceit = state?.profile.faceit;
view = createGamerModeScreen({
profile: state ? gamingProfile(state, deps.self, api) : deps.self,
profile: { ...(state ? gamingProfile(state, deps.self, api) : deps.self), epic: { linked: !!epicState?.account } },
epicConnection: epicConnection.root,
locale: i18n.locale,
initialEnabled: state?.settings.enabled ?? false,
available: fresh,
Expand Down Expand Up @@ -139,5 +146,6 @@ export function createGamingScreen(deps: {
view?.destroy();
root.remove();
epicLibrary.destroy();
epicConnection.destroy();
} };
}
92 changes: 92 additions & 0 deletions clients/ui/src/screens/provider_connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { el } from "../dom.ts";
import type { ApiLike } from "./api.ts";

export type AccountProvider = "epic" | "tiktok";
export interface ProviderConnectionState {
available: boolean;
account: { id: string; name: string } | null;
}
export function providerBase(provider: AccountProvider): string {
return provider === "epic" ? "/v1/gaming/connect/epic" : "/v1/social/connect/tiktok";
}
export function providerAuthorizationUrl(raw: string, provider: AccountProvider): string {
const url = new URL(raw);
const host = provider === "epic" ? "www.epicgames.com" : "www.tiktok.com";
const path = provider === "epic" ? "/id/authorize" : "/v2/auth/authorize/";
if (url.protocol !== "https:" || url.hostname !== host || url.pathname !== path ||
url.username || url.password || url.port || url.hash || url.searchParams.get("response_type") !== "code" ||
!url.searchParams.get("state")) throw new Error("Invalid authorization URL");
return url.href;
}
export function createProviderConnection(deps: {
provider: AccountProvider; locale: string; api: ApiLike;
openExternal?(url: string): Promise<void> | void;
onChange?(state: ProviderConnectionState | null): void;
}) {
const ru = deps.locale.startsWith("ru");
const copy = (a: string, b: string) => ru ? a : b;
const name = deps.provider === "epic" ? "Epic Games" : "TikTok";
const base = providerBase(deps.provider);
const root = el("div", { class: "gc-provider-connection" });
const status = el("p", { role: "status", "aria-live": "polite" });
const connect = el("button", { type: "button", class: "gc-btn" }, [copy("Подключить", "Connect")]) as HTMLButtonElement;
const refresh = el("button", { type: "button", class: "gc-btn" }, [copy("Проверить подключение", "Check connection")]) as HTMLButtonElement;
root.append(status, connect, refresh);
let state: ProviderConnectionState | null = null;
let busy = false;
let destroyed = false;
let waiting = false;
function paint(message?: string) {
status.textContent = message ?? (state?.account ? `${copy("Подключён", "Connected")}: ${state.account.name}`
: waiting ? copy(`Завершите вход в ${name} в браузере.`, `Finish signing in to ${name} in your browser.`)
: state?.available ? copy(`Войдите в ${name}, чтобы подтвердить свой аккаунт.`, `Sign in to ${name} to verify your account.`)
: copy(`Подключение ${name} ещё не настроено на сервере.`, `${name} connection has not been configured on this server yet.`));
connect.textContent = state?.account ? copy("Отключить", "Disconnect") : copy("Подключить", "Connect");
connect.disabled = busy || !state || (!state.available && !state.account);
refresh.disabled = busy;
}
async function read() {
const next = await deps.api.get<ProviderConnectionState>(`${base}/status`);
if (!next || typeof next.available !== "boolean" || !(next.account === null ||
(typeof next.account?.id === "string" && !!next.account.id && typeof next.account.name === "string"))) {
throw new Error("Invalid connection state");
}
if (destroyed) return;
state = next;
if (next.account) waiting = false;
deps.onChange?.(next);
}
async function run(action: () => Promise<void>) {
if (busy || destroyed) return;
busy = true; paint(copy("Проверка…", "Checking…"));
try { await action(); if (!destroyed) { busy = false; paint(); } }
catch { if (!destroyed) { busy = false; state = null; deps.onChange?.(null);
paint(copy("Не удалось проверить подключение. Повторите попытку.", "Could not verify the connection. Try again.")); } }
}
connect.addEventListener("click", () => { void run(async () => {
if (state?.account) {
await deps.api.delete(`${base}/connection`); waiting = false;
} else {
const result = await deps.api.post<{ url: string }>(`${base}/start`);
if (destroyed) return;
const url = providerAuthorizationUrl(result.url, deps.provider);
const native = (window as Window & { __gcOpenProviderAuthorization?: (url: string) => Promise<void> }).__gcOpenProviderAuthorization;
if (deps.openExternal) await deps.openExternal(url);
else if (native) await native(url);
else window.open(url, "_blank", "noopener,noreferrer");
waiting = true;
}
await read();
}); });
const wake = () => { if (document.visibilityState !== "hidden") void run(read); };
refresh.addEventListener("click", wake);
window.addEventListener("focus", wake);
window.addEventListener("online", wake);
document.addEventListener("visibilitychange", wake);
const timer = setInterval(wake, 30_000);
void run(read);
return { root, destroy() { destroyed = true; clearInterval(timer);
window.removeEventListener("focus", wake); window.removeEventListener("online", wake);
document.removeEventListener("visibilitychange", wake); root.remove(); } };
}
8 changes: 6 additions & 2 deletions clients/ui/src/screens/social_screen.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { el } from "../dom.ts";
import { tiktokPost } from "./tiktok_model.ts";
import type { ApiLike } from "./api.ts";
import { createProviderConnection } from "./provider_connection.ts";

export function createSocialScreen(deps: { locale: string; onBack(): void }) {
export function createSocialScreen(deps: { locale: string; api?: ApiLike; onBack(): void }) {
const ru = deps.locale.startsWith("ru");
const copy = (a: string, b: string) => ru ? a : b;
const root = el("section", { class: "gc-social-page" });
Expand All @@ -20,6 +22,8 @@ export function createSocialScreen(deps: { locale: string; onBack(): void }) {
"Вставь полную ссылку на публичное видео или фото TikTok. Плеер загрузится с TikTok после нажатия «Смотреть».",
"Paste the full link to a public TikTok video or photo. The TikTok player loads when you select Watch.")]),
form, status, feed);
const connection = deps.api ? createProviderConnection({ provider: "tiktok", locale: deps.locale, api: deps.api }) : null;
if (connection) root.insertBefore(connection.root, form);
let frame: HTMLIFrameElement | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let destroyed = false;
Expand Down Expand Up @@ -67,5 +71,5 @@ export function createSocialScreen(deps: { locale: string; onBack(): void }) {
document.addEventListener("visibilitychange", visibility);
return { root, destroy() { destroyed = true; stopTimer(); window.removeEventListener("message", onMessage);
document.removeEventListener("visibilitychange", visibility);
form.removeEventListener("submit", onSubmit); frame?.remove(); frame = null; root.remove(); } };
form.removeEventListener("submit", onSubmit); connection?.destroy(); frame?.remove(); frame = null; root.remove(); } };
}
47 changes: 47 additions & 0 deletions clients/ui/test/provider_connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createProviderConnection, providerAuthorizationUrl } from "../src/screens/provider_connection.ts";
import type { ApiLike } from "../src/screens/api.ts";
import { installDomStub, StubNode, settle, deferred } from "./dom_stub.ts";
const all = (n: StubNode): StubNode[] => [n, ...n.children.flatMap(all)];
const button = (root: HTMLElement, text: string) => all(root as unknown as StubNode).find(n => n.tag === "button" && n.textContent === text)!;
function env() { installDomStub(); Object.assign(globalThis, { window: { addEventListener() {}, removeEventListener() {} } }); }
test("provider authentication rejects alternate hosts, paths and missing state", () => {
assert.match(providerAuthorizationUrl("https://www.epicgames.com/id/authorize?response_type=code&state=one", "epic"), /epicgames/);
for (const url of ["https://www.tiktok.com/evil?response_type=code&state=a", "https://www.tiktok.com.evil.test/v2/auth/authorize/?response_type=code&state=a",
"https://www.tiktok.com/v2/auth/authorize/?response_type=code", "https://user@www.tiktok.com/v2/auth/authorize/?response_type=code&state=a"])
assert.throws(() => providerAuthorizationUrl(url, "tiktok"));
});
test("opening browser never claims connected; server identity controls link and unlink", async () => {
env(); let account: null | { id: string; name: string } = null; const calls: string[] = []; let opened = "";
const api = { get: async (path: string) => { calls.push(path); return { available: true, account }; },
post: async (path: string) => { calls.push(path); return { url: "https://www.epicgames.com/id/authorize?response_type=code&state=one" }; },
delete: async (path: string) => { calls.push(path); account = null; } } as unknown as ApiLike;
const screen = createProviderConnection({ provider: "epic", locale: "en", api, openExternal: url => { opened = url; } });
try {
await settle(); button(screen.root, "Connect").dispatch("click"); await settle();
assert.match(opened, /epicgames/); assert.match(screen.root.textContent ?? "", /Finish signing in/);
assert.doesNotMatch(screen.root.textContent ?? "", /Connected:/);
account = { id: "verified-id", name: "Verified player" };
button(screen.root, "Check connection").dispatch("click"); await settle();
assert.match(screen.root.textContent ?? "", /Connected: Verified player/);
button(screen.root, "Disconnect").dispatch("click"); await settle();
assert.ok(calls.includes("/v1/gaming/connect/epic/connection"));
assert.doesNotMatch(screen.root.textContent ?? "", /Connected:/);
} finally { screen.destroy(); }
});
test("unconfigured TikTok stays social and cannot start authentication", async () => {
env(); const paths: string[] = [];
const api = { get: async (path: string) => { paths.push(path); return { available: false, account: null }; } } as unknown as ApiLike;
const screen = createProviderConnection({ provider: "tiktok", locale: "en", api });
try { await settle(); assert.equal(button(screen.root, "Connect").disabled, true);
assert.deepEqual(paths, ["/v1/social/connect/tiktok/status"]); } finally { screen.destroy(); }
});
test("destroying a view during start prevents late browser navigation", async () => {
env(); let opened = false; const pending = deferred<{ url: string }>();
const api = { get: async () => ({ available: true, account: null }), post: () => pending.promise } as unknown as ApiLike;
const screen = createProviderConnection({ provider: "tiktok", locale: "en", api, openExternal() { opened = true; } });
await settle(); button(screen.root, "Connect").dispatch("click"); screen.destroy();
pending.resolve({ url: "https://www.tiktok.com/v2/auth/authorize/?response_type=code&state=one" });
await settle(); assert.equal(opened, false);
});