Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 7d7a379ce0 feat(desktop): call page on its own loopback origin, opt-in (#43)
CI / Build & Quality Checks (pull_request) Successful in 1m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m24s
The desktop app loads the bundled Element Call page from its own origin
(http://localhost:<port>), so the call frame can read the app's storage
(login token) and DOM — the hole #43 closed on the web by moving the page
to call.chat.lotusguild.org.

The desktop's local server can also answer on http://127.0.0.1:<port>: the
same server and bundle, but a different origin (and still a secure
context). resolveDesktopCallPageUrl loads the bundled page from there when
the desktop config sets `desktopCallOrigin`:
- only a loopback http origin on the SAME port as the app, no path, query
  or credentials;
- only when the app itself runs on http://localhost (release builds; debug
  builds on tauri:// keep the same-origin page);
- unset (every desktop build until cinny-desktop opts in, together with the
  server bind, CSP and permission changes it needs): unchanged.

The web app is unchanged (elementCallUrl as before).

Tested in a simulated desktop app (Tauri bridge stub + the desktop
config.json, served on localhost and 127.0.0.1) against a local Synapse +
LiveKit, two users: call page from http://127.0.0.1:<port>, parentUrl =
the app origin; the frame gets SecurityError on parent.localStorage and
parent.document (same-origin control: readable); join, speaking indicator,
mic off/on, screenshare start/stop, layout switch and hang-up all work, no
page errors — 12/12 in 5 of 6 runs, like the same-origin control (3 of 4;
the misses on both sides were the local LiveKit connection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:09:27 -04:00
10 changed files with 102 additions and 333 deletions
@@ -114,7 +114,6 @@ import { SequenceCardStyle } from '../styles.css';
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { describeUpdateError, manualDownloadUrl } from '../../../utils/updateErrors';
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
import { useKeychainMirrorStatus } from '../../../state/keychainMirror';
import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys';
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
@@ -239,27 +238,6 @@ function AutostartSetting() {
);
}
// [Gitea #105] Desktop: whether the login is also kept in the OS keychain.
function KeychainMirrorSetting() {
const status = useKeychainMirrorStatus();
if (!isTauriEnv() || status.state === 'idle' || status.state === 'cleared') return null;
let description: string;
if (status.state === 'ok') {
description =
"A copy of your login is kept in the system keychain (Windows Credential Manager). A later update will keep it only there, out of the app's data folder.";
} else if (status.state === 'unsupported') {
description =
"Not available on this system yet. Your login is saved in the app's data folder, as before.";
} else {
description = `Couldn't save a copy to the system keychain (${status.error}). You stay logged in; your login is saved in the app's data folder, as before.`;
}
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Login in the system keychain" description={description} />
</SequenceCard>
);
}
type ThemeSelectorProps = {
themeNames: Record<string, string>;
themes: Theme[];
@@ -592,7 +570,6 @@ function Appearance() {
<DesktopChromeSetting />
<AutostartSetting />
<KeychainMirrorSetting />
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
+9
View File
@@ -26,6 +26,15 @@ export type ClientConfig = {
*/
elementCallUrl?: string;
/**
* [Gitea #43] Desktop only: the loopback origin the desktop app's local
* server also answers on (e.g. "http://127.0.0.1:44548"), to load the
* bundled call page from a different origin than the app
* ("http://localhost:44548"). Set by cinny-desktop together with the server
* and CSP changes it needs; unset keeps the same-origin call page.
*/
desktopCallOrigin?: string;
/**
* Absolute https URL of the public web app (e.g. https://chat.lotusguild.org).
* The desktop app sets it so it can hand calls it can't make to the browser.
+14 -2
View File
@@ -36,7 +36,11 @@ import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
import { OIDC_CALLBACK_PATH } from './paths';
import { OidcCallback } from './auth/oidc/OidcCallback';
import { resolveCallPageUrl, setCallPageUrl } from '../plugins/call/callPageUrl';
import {
resolveCallPageUrl,
resolveDesktopCallPageUrl,
setCallPageUrl,
} from '../plugins/call/callPageUrl';
// The emoji families (Twemoji when "Twitter emoji" is on, Twemoji flags on
// Windows — see SystemEmojiFeature) must sit before the generic family, or the
@@ -223,7 +227,15 @@ function App() {
>
{(clientConfig) => {
// [Gitea #43] Idempotent: where the call page is loaded from.
setCallPageUrl(resolveCallPageUrl(clientConfig.elementCallUrl, isTauri()));
setCallPageUrl(
isTauri()
? resolveDesktopCallPageUrl(
clientConfig.desktopCallOrigin,
window.location.origin,
import.meta.env.BASE_URL,
)
: resolveCallPageUrl(clientConfig.elementCallUrl, false),
);
return (
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
+42 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveCallPageUrl } from './callPageUrl';
import { resolveCallPageUrl, resolveDesktopCallPageUrl } from './callPageUrl';
const URL_OK = 'https://call.chat.example.org/public/element-call/index.html';
@@ -38,3 +38,44 @@ test('anything else falls back to the bundled page', () => {
'data:text/html,x',
].forEach((v) => assert.equal(resolveCallPageUrl(v, false), undefined, String(v)));
});
const APP = 'http://localhost:44548';
const PAGE = '/public/element-call/index.html';
test('desktop: the bundled page from the loopback origin on the same port', () => {
assert.equal(
resolveDesktopCallPageUrl('http://127.0.0.1:44548', APP, '/'),
`http://127.0.0.1:44548${PAGE}`,
);
assert.equal(
resolveDesktopCallPageUrl('http://127.0.0.1:44548/', APP, '/app/'),
`http://127.0.0.1:44548/app${PAGE}`,
);
});
test('desktop: unset or anything but same-port loopback http keeps the same-origin page', () => {
[
undefined,
'',
'http://127.0.0.1:44549',
'http://127.0.0.1',
'https://127.0.0.1:44548',
'http://localhost:44548',
'http://[::1]:44548',
'http://10.0.0.5:44548',
'https://call.chat.lotusguild.org',
'http://127.0.0.1:44548/evil/',
'http://127.0.0.1:44548/?x=1',
'http://user:pw@127.0.0.1:44548',
'not a url',
42,
].forEach((v) => assert.equal(resolveDesktopCallPageUrl(v, APP, '/'), undefined, String(v)));
});
test('desktop: only when the app itself runs on http://localhost (release builds)', () => {
const v = 'http://127.0.0.1:44548';
assert.equal(resolveDesktopCallPageUrl(v, 'tauri://localhost', '/'), undefined);
assert.equal(resolveDesktopCallPageUrl(v, 'http://tauri.localhost', '/'), undefined);
assert.equal(resolveDesktopCallPageUrl(v, 'https://chat.lotusguild.org', '/'), undefined);
assert.equal(resolveDesktopCallPageUrl(v, 'http://localhost', '/'), undefined);
});
+37 -3
View File
@@ -7,9 +7,9 @@
* app loads it from that origin instead, so the call frame can no longer
* reach this origin's storage (login token, crypto store) or service worker.
*
* Web only: the desktop app keeps its bundled copy (its CSP doesn't allow
* another frame origin, and a network copy could drift from the bundle).
* Anything that isn't an absolute https URL (http only on localhost, for
* Web only: the desktop app keeps its bundled copy (a network copy could
* drift from the bundle); see resolveDesktopCallPageUrl for how it isolates
* it. Anything that isn't an absolute https URL (http only on localhost, for
* development) is ignored, so a bad value falls
* back to the bundled page instead of breaking calls.
*/
@@ -28,6 +28,40 @@ export const resolveCallPageUrl = (value: unknown, desktop: boolean): string | u
}
};
/**
* [Gitea #43] Desktop: the bundled call page from a second origin.
*
* The desktop app is served by its local server at http://localhost:<port>.
* The same server answers on http://127.0.0.1:<port>, which is a different
* origin (and still a secure context), so loading the bundled call page from
* there cuts the call frame off from the app's storage (login token, crypto
* store) without a network copy that could drift from the bundle.
*
* Only used when cinny-desktop sets `desktopCallOrigin` (it ships the server
* and CSP changes this needs in the same release), only for a loopback http
* origin on the SAME port as the app, and only when the app itself runs on
* http://localhost (release builds). Anything else keeps the same-origin page.
*/
export const resolveDesktopCallPageUrl = (
value: unknown,
appOrigin: string,
basePath: string,
): string | undefined => {
if (typeof value !== 'string' || value.trim() === '') return undefined;
try {
const app = new URL(appOrigin);
const call = new URL(value);
if (app.protocol !== 'http:' || app.hostname !== 'localhost' || !app.port) return undefined;
if (call.protocol !== 'http:' || call.hostname !== '127.0.0.1') return undefined;
if (call.port !== app.port || call.username || call.password) return undefined;
if (call.pathname !== '/' || call.search || call.hash) return undefined;
const base = basePath.replace(/\/+$/, '');
return `${call.origin}${base}/public/element-call/index.html`;
} catch {
return undefined;
}
};
let callPageUrl: string | undefined;
export const setCallPageUrl = (url: string | undefined): void => {
-117
View File
@@ -1,117 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { KeychainInvoke, sameTokens, syncKeychain, tokensOf } from './keychainMirror';
import type { Session } from './sessions';
const session: Session = {
baseUrl: 'https://matrix.example.org',
userId: '@alice:example.org',
deviceId: 'DEV1',
accessToken: 'syt_token',
refreshToken: 'mar_refresh',
};
/** An in-memory keychain behind the same commands the desktop app exposes. */
const fakeKeychain = (opts: { supported?: boolean; failSet?: boolean; corrupt?: boolean } = {}) => {
let stored: unknown = null;
const calls: string[] = [];
const invoke: KeychainInvoke = async (cmd, args) => {
calls.push(cmd);
switch (cmd) {
case 'secure_session_supported':
return opts.supported ?? true;
case 'secure_session_get':
return stored;
case 'secure_session_set':
if (opts.failSet) throw new Error('Access is denied.');
stored = opts.corrupt ? { ...(args?.tokens as object), accessToken: 'x' } : args?.tokens;
return null;
case 'secure_session_clear':
stored = null;
return null;
default:
throw new Error(`unknown ${cmd}`);
}
};
return { invoke, calls, get: () => stored };
};
test('stores the tokens (not the whole session) and verifies them', async () => {
const kc = fakeKeychain();
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'ok' });
assert.deepEqual(kc.get(), {
userId: '@alice:example.org',
deviceId: 'DEV1',
accessToken: 'syt_token',
refreshToken: 'mar_refresh',
});
assert.deepEqual(kc.calls, [
'secure_session_supported',
'secure_session_get',
'secure_session_set',
'secure_session_get',
]);
});
test('an up-to-date copy is not rewritten', async () => {
const kc = fakeKeychain();
await syncKeychain(kc.invoke, session);
kc.calls.length = 0;
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'ok' });
assert.deepEqual(kc.calls, ['secure_session_supported', 'secure_session_get']);
});
test('a token rotation rewrites; no session clears', async () => {
const kc = fakeKeychain();
await syncKeychain(kc.invoke, session);
await syncKeychain(kc.invoke, { ...session, accessToken: 'syt_new', refreshToken: undefined });
assert.equal((kc.get() as { accessToken: string }).accessToken, 'syt_new');
assert.equal('refreshToken' in (kc.get() as object), false);
assert.deepEqual(await syncKeychain(kc.invoke, null), { state: 'cleared' });
assert.equal(kc.get(), null);
});
test('unsupported platform: nothing is touched', async () => {
const kc = fakeKeychain({ supported: false });
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'unsupported' });
assert.deepEqual(kc.calls, ['secure_session_supported']);
});
test('failures become a status, never an exception', async () => {
assert.deepEqual(await syncKeychain(fakeKeychain({ failSet: true }).invoke, session), {
state: 'error',
error: 'Access is denied.',
});
assert.deepEqual(await syncKeychain(fakeKeychain({ corrupt: true }).invoke, session), {
state: 'error',
error: 'read-back did not match',
});
// The credential store hangs (the support check itself is instant).
const hung: KeychainInvoke = async (cmd) =>
cmd === 'secure_session_supported'
? true
: new Promise(() => {
/* never settles */
});
assert.deepEqual(await syncKeychain(hung, session, 50), {
state: 'error',
error: 'keychain timed out',
});
});
test('a desktop build without the commands reads as unsupported, not an error', async () => {
const missingCommand: KeychainInvoke = async (cmd) => {
throw new Error(`Command ${cmd} not found`);
};
assert.deepEqual(await syncKeychain(missingCommand, session), { state: 'unsupported' });
});
test('sameTokens compares only the secrets, treating a missing refresh token as absent', () => {
const t = tokensOf({ ...session, refreshToken: undefined });
assert.equal(sameTokens({ ...t }, t), true);
assert.equal(sameTokens({ ...t, refreshToken: undefined }, t), true);
assert.equal(sameTokens({ ...t, accessToken: 'other' }, t), false);
assert.equal(sameTokens({ ...t, refreshToken: 'r' }, t), false);
assert.equal(sameTokens(null, t), false);
assert.equal(sameTokens('string', t), false);
});
-141
View File
@@ -1,141 +0,0 @@
import { useEffect, useState } from 'react';
import { getFallbackSession, onSessionPersisted, Session } from './sessions';
/**
* [Gitea #105] Desktop, step 1: mirror the login tokens into the OS keychain.
*
* The session is still read from localStorage exactly as before; this only
* keeps a copy in the keychain (Windows Credential Manager) and checks it by
* reading it back, so real installs prove the keychain works before step 2
* switches reads over to it. Nothing here can log anyone out: every failure
* just records a status for Settings.
*
* Writes are serialized (a token rotation right after login must not race
* the login's write) and time out, so a hung credential store can't pile up.
* When there's no session the keychain entry is cleared, which also covers a
* logout whose page reload beat the clear.
*/
export type KeychainTokens = {
userId: string;
deviceId: string;
accessToken: string;
refreshToken?: string;
};
export type KeychainMirrorStatus =
| { state: 'idle' }
| { state: 'unsupported' }
| { state: 'ok' }
| { state: 'cleared' }
| { state: 'error'; error: string };
export type KeychainInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
export const KEYCHAIN_TIMEOUT_MS = 5000;
export const tokensOf = (session: Session): KeychainTokens => ({
userId: session.userId,
deviceId: session.deviceId,
accessToken: session.accessToken,
...(session.refreshToken ? { refreshToken: session.refreshToken } : {}),
});
export const sameTokens = (a: unknown, b: KeychainTokens): boolean => {
if (!a || typeof a !== 'object') return false;
const t = a as Partial<KeychainTokens>;
return (
t.userId === b.userId &&
t.deviceId === b.deviceId &&
t.accessToken === b.accessToken &&
(t.refreshToken ?? undefined) === (b.refreshToken ?? undefined)
);
};
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('keychain timed out')), ms);
p.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
},
);
});
const errorText = (e: unknown): string =>
(e instanceof Error ? e.message : String(e)).slice(0, 200);
/**
* Bring the keychain in line with `session` (null = no session). Reads first
* and only writes when the stored copy differs, then verifies by reading back.
*/
export const syncKeychain = async (
invoke: KeychainInvoke,
session: Session | null,
timeoutMs = KEYCHAIN_TIMEOUT_MS,
): Promise<KeychainMirrorStatus> => {
// A desktop build without the commands (older than this feature) rejects
// the call: that is "not supported", not an error to show the user.
let supported: unknown;
try {
supported = await withTimeout(invoke('secure_session_supported'), timeoutMs);
} catch {
supported = false;
}
if (supported !== true) return { state: 'unsupported' };
try {
if (!session) {
await withTimeout(invoke('secure_session_clear'), timeoutMs);
return { state: 'cleared' };
}
const tokens = tokensOf(session);
const stored = await withTimeout(invoke('secure_session_get'), timeoutMs);
if (sameTokens(stored, tokens)) return { state: 'ok' };
await withTimeout(invoke('secure_session_set', { tokens }), timeoutMs);
const back = await withTimeout(invoke('secure_session_get'), timeoutMs);
if (!sameTokens(back, tokens)) return { state: 'error', error: 'read-back did not match' };
return { state: 'ok' };
} catch (e) {
return { state: 'error', error: errorText(e) };
}
};
let status: KeychainMirrorStatus = { state: 'idle' };
const statusListeners = new Set<(s: KeychainMirrorStatus) => void>();
const setStatus = (next: KeychainMirrorStatus): void => {
status = next;
statusListeners.forEach((cb) => cb(next));
};
export const getKeychainMirrorStatus = (): KeychainMirrorStatus => status;
let started = false;
/** Start mirroring (desktop only; call once at boot). */
export const startKeychainMirror = (invoke: KeychainInvoke | undefined): void => {
if (!invoke || started) return;
started = true;
let chain: Promise<unknown> = Promise.resolve();
const enqueue = (session: Session | null) => {
chain = chain.then(async () => setStatus(await syncKeychain(invoke, session)));
};
enqueue(getFallbackSession() ?? null);
onSessionPersisted((session) => enqueue(session));
};
export const useKeychainMirrorStatus = (): KeychainMirrorStatus => {
const [value, setValue] = useState(status);
useEffect(() => {
setValue(status);
statusListeners.add(setValue);
return () => {
statusListeners.delete(setValue);
};
}, []);
return value;
};
-17
View File
@@ -432,20 +432,3 @@ test('subscribeSessionChanges ignores unrelated storage keys', () => {
listeners.forEach((cb) => cb({ key: 'some_unrelated_preference' }));
assert.equal(fired, false);
});
test('onSessionPersisted: told about writes and removals; a throwing listener is harmless', async () => {
installStorage();
const { onSessionPersisted } = await import('./sessions');
const seen: (string | null)[] = [];
const offBad = onSessionPersisted(() => {
throw new Error('boom');
});
const off = onSessionPersisted((s) => seen.push(s ? s.accessToken : null));
setFallbackSession('tok-a', 'DEV', '@a:hs', 'https://hs', { refreshToken: 'ref-a' });
removeFallbackSession();
off();
offBad();
setFallbackSession('tok-b', 'DEV', '@a:hs', 'https://hs');
assert.deepEqual(seen, ['tok-a', null]);
assert.equal(getFallbackSession()?.accessToken, 'tok-b', 'write still happened');
});
-23
View File
@@ -233,27 +233,6 @@ export type SessionStoreName = {
// crypto: 'crypto-store',
// } as const;
// [Gitea #105] Listeners told about every session write in THIS tab (login,
// token rotation) and removal (logout), e.g. the desktop keychain mirror.
// A throwing listener never breaks the write.
type PersistListener = (session: Session | null) => void;
const persistListeners = new Set<PersistListener>();
const notifyPersisted = (session: Session | null): void => {
persistListeners.forEach((cb) => {
try {
cb(session);
} catch {
/* listener errors must not affect login/logout */
}
});
};
export const onSessionPersisted = (cb: PersistListener): (() => void) => {
persistListeners.add(cb);
return () => {
persistListeners.delete(cb);
};
};
// Persist the session. Writes the atomic blob FIRST (so the consistent,
// never-torn copy is established before the multi-key legacy write), then
// dual-writes the legacy keys for rollback safety. Signature is unchanged —
@@ -270,7 +249,6 @@ export function setFallbackSession(
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
// Dual-write the legacy keys (removal of this half is a future release).
writeLegacyKeys(persisted);
notifyPersisted(sessionFromPersisted(persisted));
}
// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring
@@ -279,7 +257,6 @@ export const removeFallbackSession = () => {
localStorage.removeItem(SESSION_BLOB_KEY);
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
notifyPersisted(null);
};
// Read the session, preferring the atomic blob. If the blob is absent or
-6
View File
@@ -17,16 +17,10 @@ import App from './app/pages/App';
import './app/i18n';
import { pushSessionToSW } from './sw-session';
import { getFallbackSession } from './app/state/sessions';
import { startKeychainMirror } from './app/state/keychainMirror';
import { tauriInvoke } from './app/hooks/useTauri';
import { cleanupSearchCacheIfSignedOut } from './client/initMatrix';
document.body.classList.add(configClass, varsClass);
// [Gitea #105] Desktop: keep a copy of the login tokens in the OS keychain
// (step 1: mirror only; the session is still read from localStorage).
startKeychainMirror(tauriInvoke());
// Register Service Worker
// Service workers only register on http(s) pages. The desktop app loads from
// `tauri://localhost` in debug builds (and on any platform where the localhost