mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-03 18:22:42 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
269
src/main/runtime/jellyfin-setup-window.ts
Normal file
269
src/main/runtime/jellyfin-setup-window.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
type JellyfinClientInfo = {
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
deviceId: string;
|
||||
};
|
||||
|
||||
type FocusableWindowLike = {
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
type JellyfinSetupWebContentsLike = {
|
||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
||||
};
|
||||
|
||||
type JellyfinSetupWindowLike = FocusableWindowLike & {
|
||||
webContents: JellyfinSetupWebContentsLike;
|
||||
loadURL: (url: string) => unknown;
|
||||
on: (event: 'closed', handler: () => void) => void;
|
||||
isDestroyed: () => boolean;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
function escapeHtmlAttr(value: string): string {
|
||||
return value.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: {
|
||||
getSetupWindow: () => FocusableWindowLike | null;
|
||||
}) {
|
||||
return (): boolean => {
|
||||
const window = deps.getSetupWindow();
|
||||
if (!window) {
|
||||
return false;
|
||||
}
|
||||
window.focus();
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJellyfinSetupFormHtml(defaultServer: string, defaultUser: string): string {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Jellyfin Setup</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #0b1020; color: #e5e7eb; }
|
||||
main { padding: 20px; }
|
||||
h1 { margin: 0 0 8px; font-size: 22px; }
|
||||
p { margin: 0 0 14px; color: #cbd5e1; font-size: 13px; line-height: 1.4; }
|
||||
label { display: block; margin: 10px 0 4px; font-size: 13px; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 9px 10px; border: 1px solid #334155; border-radius: 8px; background: #111827; color: #e5e7eb; }
|
||||
button { margin-top: 16px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; font-weight: 600; cursor: pointer; background: #2563eb; color: #f8fafc; }
|
||||
.hint { margin-top: 12px; font-size: 12px; color: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Jellyfin Setup</h1>
|
||||
<p>Login info is used to fetch a token and save Jellyfin config values.</p>
|
||||
<form id="form">
|
||||
<label for="server">Server URL</label>
|
||||
<input id="server" name="server" value="${escapeHtmlAttr(defaultServer)}" required />
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" value="${escapeHtmlAttr(defaultUser)}" required />
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required />
|
||||
<button type="submit">Save and Login</button>
|
||||
<div class="hint">Equivalent CLI: --jellyfin-login --jellyfin-server ... --jellyfin-username ... --jellyfin-password ...</div>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
const form = document.getElementById("form");
|
||||
form?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
params.set("server", String(data.get("server") || ""));
|
||||
params.set("username", String(data.get("username") || ""));
|
||||
params.set("password", String(data.get("password") || ""));
|
||||
window.location.href = "subminer://jellyfin-setup?" + params.toString();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
|
||||
server: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} | null {
|
||||
if (!rawUrl.startsWith('subminer://jellyfin-setup')) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new URL(rawUrl);
|
||||
return {
|
||||
server: parsed.searchParams.get('server') || '',
|
||||
username: parsed.searchParams.get('username') || '',
|
||||
password: parsed.searchParams.get('password') || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
||||
parseSubmissionUrl: (rawUrl: string) => { server: string; username: string; password: string } | null;
|
||||
authenticateWithPassword: (
|
||||
server: string,
|
||||
username: string,
|
||||
password: string,
|
||||
clientInfo: JellyfinClientInfo,
|
||||
) => Promise<JellyfinSession>;
|
||||
getJellyfinClientInfo: () => JellyfinClientInfo;
|
||||
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
|
||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
closeSetupWindow: () => void;
|
||||
}) {
|
||||
return async (rawUrl: string): Promise<boolean> => {
|
||||
const submission = deps.parseSubmissionUrl(rawUrl);
|
||||
if (!submission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await deps.authenticateWithPassword(
|
||||
submission.server,
|
||||
submission.username,
|
||||
submission.password,
|
||||
deps.getJellyfinClientInfo(),
|
||||
);
|
||||
deps.saveStoredSession({
|
||||
accessToken: session.accessToken,
|
||||
userId: session.userId,
|
||||
});
|
||||
deps.patchJellyfinConfig(session);
|
||||
deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
|
||||
deps.showMpvOsd('Jellyfin login success');
|
||||
deps.closeSetupWindow();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.logError('Jellyfin setup failed', error);
|
||||
deps.showMpvOsd(`Jellyfin login failed: ${message}`);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleJellyfinSetupNavigationHandler(deps: {
|
||||
setupSchemePrefix: string;
|
||||
handleSubmission: (rawUrl: string) => Promise<unknown>;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return (params: { url: string; preventDefault: () => void }): boolean => {
|
||||
if (!params.url.startsWith(deps.setupSchemePrefix)) {
|
||||
return false;
|
||||
}
|
||||
params.preventDefault();
|
||||
void deps.handleSubmission(params.url).catch((error) => {
|
||||
deps.logError('Failed handling Jellyfin setup submission', error);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleJellyfinSetupWindowClosedHandler(deps: {
|
||||
clearSetupWindow: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.clearSetupWindow();
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleJellyfinSetupWindowOpenedHandler(deps: {
|
||||
setSetupWindow: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.setSetupWindow();
|
||||
};
|
||||
}
|
||||
|
||||
export function createOpenJellyfinSetupWindowHandler<TWindow extends JellyfinSetupWindowLike>(deps: {
|
||||
maybeFocusExistingSetupWindow: () => boolean;
|
||||
createSetupWindow: () => TWindow;
|
||||
getResolvedJellyfinConfig: () => { serverUrl?: string | null; username?: string | null };
|
||||
buildSetupFormHtml: (defaultServer: string, defaultUser: string) => string;
|
||||
parseSubmissionUrl: (rawUrl: string) => { server: string; username: string; password: string } | null;
|
||||
authenticateWithPassword: (
|
||||
server: string,
|
||||
username: string,
|
||||
password: string,
|
||||
clientInfo: JellyfinClientInfo,
|
||||
) => Promise<JellyfinSession>;
|
||||
getJellyfinClientInfo: () => JellyfinClientInfo;
|
||||
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
|
||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
clearSetupWindow: () => void;
|
||||
setSetupWindow: (window: TWindow) => void;
|
||||
encodeURIComponent: (value: string) => string;
|
||||
}) {
|
||||
return (): void => {
|
||||
if (deps.maybeFocusExistingSetupWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setupWindow = deps.createSetupWindow();
|
||||
const defaults = deps.getResolvedJellyfinConfig();
|
||||
const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096';
|
||||
const defaultUser = defaults.username || '';
|
||||
const formHtml = deps.buildSetupFormHtml(defaultServer, defaultUser);
|
||||
const handleSubmission = createHandleJellyfinSetupSubmissionHandler({
|
||||
parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword: (server, username, password, clientInfo) =>
|
||||
deps.authenticateWithPassword(server, username, password, clientInfo),
|
||||
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
|
||||
saveStoredSession: (session) => deps.saveStoredSession(session),
|
||||
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
|
||||
logInfo: (message) => deps.logInfo(message),
|
||||
logError: (message, error) => deps.logError(message, error),
|
||||
showMpvOsd: (message) => deps.showMpvOsd(message),
|
||||
closeSetupWindow: () => {
|
||||
if (!setupWindow.isDestroyed()) {
|
||||
setupWindow.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
const handleNavigation = createHandleJellyfinSetupNavigationHandler({
|
||||
setupSchemePrefix: 'subminer://jellyfin-setup',
|
||||
handleSubmission: (rawUrl) => handleSubmission(rawUrl),
|
||||
logError: (message, error) => deps.logError(message, error),
|
||||
});
|
||||
const handleWindowClosed = createHandleJellyfinSetupWindowClosedHandler({
|
||||
clearSetupWindow: () => deps.clearSetupWindow(),
|
||||
});
|
||||
const handleWindowOpened = createHandleJellyfinSetupWindowOpenedHandler({
|
||||
setSetupWindow: () => deps.setSetupWindow(setupWindow),
|
||||
});
|
||||
|
||||
setupWindow.webContents.on('will-navigate', (event, url) => {
|
||||
handleNavigation({
|
||||
url,
|
||||
preventDefault: () => {
|
||||
if (event && typeof event === 'object' && 'preventDefault' in event) {
|
||||
const typedEvent = event as { preventDefault?: () => void };
|
||||
typedEvent.preventDefault?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
void setupWindow.loadURL(
|
||||
`data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`,
|
||||
);
|
||||
setupWindow.on('closed', () => {
|
||||
handleWindowClosed();
|
||||
});
|
||||
handleWindowOpened();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user