feat: adding deep linking to import sources

This commit is contained in:
Chubby Granny Chaser 2024-06-22 20:15:07 +01:00
parent fbcacd7c39
commit 6d3b04fc3c
No known key found for this signature in database
11 changed files with 194 additions and 110 deletions

View File

@ -5,7 +5,6 @@ export const downloadSourceSchema = z.object({
downloads: z.array( downloads: z.array(
z.object({ z.object({
title: z.string().max(255), title: z.string().max(255),
downloaders: z.array(z.enum(["real_debrid", "torrent"])),
uris: z.array(z.string()), uris: z.array(z.string()),
uploadDate: z.string().max(255), uploadDate: z.string().max(255),
fileSize: z.string().max(255), fileSize: z.string().max(255),

View File

@ -73,7 +73,16 @@ app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window); optimizer.watchWindowShortcuts(window);
}); });
app.on("second-instance", (_event) => { const handleDeepLinkPath = (uri?: string) => {
if (!uri) return;
const url = new URL(uri);
if (url.host === "install-source") {
WindowManager.redirect(`settings${url.search}`);
}
};
app.on("second-instance", (_event, commandLine) => {
// Someone tried to run a second instance, we should focus our window. // Someone tried to run a second instance, we should focus our window.
if (WindowManager.mainWindow) { if (WindowManager.mainWindow) {
if (WindowManager.mainWindow.isMinimized()) if (WindowManager.mainWindow.isMinimized())
@ -84,16 +93,12 @@ app.on("second-instance", (_event) => {
WindowManager.createMainWindow(); WindowManager.createMainWindow();
} }
// const [, path] = commandLine.pop()?.split("://") ?? []; handleDeepLinkPath(commandLine.pop());
// if (path) {
// WindowManager.redirect(path);
// }
}); });
// app.on("open-url", (_event, url) => { app.on("open-url", (_event, url) => {
// const [, path] = url.split("://"); handleDeepLinkPath(url);
// WindowManager.redirect(path); });
// });
// Quit when all windows are closed, except on macOS. There, it's common // Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits // for applications and their menu bar to stay active until the user quits

View File

@ -9,7 +9,7 @@ import {
shell, shell,
} from "electron"; } from "electron";
import { is } from "@electron-toolkit/utils"; import { is } from "@electron-toolkit/utils";
import { t } from "i18next"; import i18next, { t } from "i18next";
import path from "node:path"; import path from "node:path";
import icon from "@resources/icon.png?asset"; import icon from "@resources/icon.png?asset";
import trayIcon from "@resources/tray-icon.png?asset"; import trayIcon from "@resources/tray-icon.png?asset";
@ -100,7 +100,13 @@ export class WindowManager {
authWindow.removeMenu(); authWindow.removeMenu();
authWindow.loadURL("https://auth.hydra.losbroxas.org/"); const searchParams = new URLSearchParams({
lng: i18next.language,
});
authWindow.loadURL(
`https://auth.hydra.losbroxas.org/?${searchParams.toString()}`
);
authWindow.once("ready-to-show", () => { authWindow.once("ready-to-show", () => {
authWindow.show(); authWindow.show();

View File

@ -1 +1,2 @@
export * from "./game-details/game-details.context"; export * from "./game-details/game-details.context";
export * from "./settings/settings.context";

View File

@ -0,0 +1,73 @@
import { createContext, useEffect, useState } from "react";
import { setUserPreferences } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import type { UserPreferences } from "@types";
import { useSearchParams } from "react-router-dom";
export interface SettingsContext {
updateUserPreferences: (values: Partial<UserPreferences>) => Promise<void>;
setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>;
clearSourceUrl: () => void;
sourceUrl: string | null;
currentCategoryIndex: number;
}
export const settingsContext = createContext<SettingsContext>({
updateUserPreferences: async () => {},
setCurrentCategoryIndex: () => {},
clearSourceUrl: () => {},
sourceUrl: null,
currentCategoryIndex: 0,
});
const { Provider } = settingsContext;
export const { Consumer: SettingsContextConsumer } = settingsContext;
export interface SettingsContextProviderProps {
children: React.ReactNode;
}
export function SettingsContextProvider({
children,
}: SettingsContextProviderProps) {
const dispatch = useAppDispatch();
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
const [searchParams] = useSearchParams();
const defaultSourceUrl = searchParams.get("urls");
useEffect(() => {
if (sourceUrl) setCurrentCategoryIndex(2);
}, [sourceUrl]);
useEffect(() => {
if (defaultSourceUrl) {
setSourceUrl(defaultSourceUrl);
}
}, [defaultSourceUrl]);
const clearSourceUrl = () => setSourceUrl(null);
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
await window.electron.updateUserPreferences(values);
window.electron.getUserPreferences().then((userPreferences) => {
dispatch(setUserPreferences(userPreferences));
});
};
return (
<Provider
value={{
updateUserPreferences,
setCurrentCategoryIndex,
clearSourceUrl,
currentCategoryIndex,
sourceUrl,
}}
>
{children}
</Provider>
);
}

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from "react"; import { useCallback, useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components"; import { Button, Modal, TextField } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
interface AddDownloadSourceModalProps { interface AddDownloadSourceModalProps {
visible: boolean; visible: boolean;
@ -23,24 +24,31 @@ export function AddDownloadSourceModal({
downloadCount: number; downloadCount: number;
} | null>(null); } | null>(null);
useEffect(() => {
setValue("");
setIsLoading(false);
setValidationResult(null);
}, [visible]);
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const handleValidateDownloadSource = async () => { const { sourceUrl } = useContext(settingsContext);
const handleValidateDownloadSource = useCallback(async (url: string) => {
setIsLoading(true); setIsLoading(true);
try { try {
const result = await window.electron.validateDownloadSource(value); const result = await window.electron.validateDownloadSource(url);
setValidationResult(result); setValidationResult(result);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; }, []);
useEffect(() => {
setValue("");
setIsLoading(false);
setValidationResult(null);
if (sourceUrl) {
setValue(sourceUrl);
handleValidateDownloadSource(sourceUrl);
}
}, [visible, handleValidateDownloadSource, sourceUrl]);
const handleAddDownloadSource = async () => { const handleAddDownloadSource = async () => {
await window.electron.addDownloadSource(value); await window.electron.addDownloadSource(value);
@ -73,7 +81,7 @@ export function AddDownloadSourceModal({
type="button" type="button"
theme="outline" theme="outline"
style={{ alignSelf: "flex-end" }} style={{ alignSelf: "flex-end" }}
onClick={handleValidateDownloadSource} onClick={() => handleValidateDownloadSource(value)}
disabled={isLoading || !value} disabled={isLoading || !value}
> >
{t("validate_download_source")} {t("validate_download_source")}

View File

@ -1,22 +1,17 @@
import { useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { UserPreferences } from "@types";
import { CheckboxField } from "@renderer/components"; import { CheckboxField } from "@renderer/components";
import { useAppSelector } from "@renderer/hooks"; import { useAppSelector } from "@renderer/hooks";
import { settingsContext } from "@renderer/context";
export interface SettingsBehaviorProps { export function SettingsBehavior() {
updateUserPreferences: (values: Partial<UserPreferences>) => void;
}
export function SettingsBehavior({
updateUserPreferences,
}: SettingsBehaviorProps) {
const userPreferences = useAppSelector( const userPreferences = useAppSelector(
(state) => state.userPreferences.value (state) => state.userPreferences.value
); );
const { updateUserPreferences } = useContext(settingsContext);
const [form, setForm] = useState({ const [form, setForm] = useState({
preferQuitInsteadOfHiding: false, preferQuitInsteadOfHiding: false,
runAtStartup: false, runAtStartup: false,

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { TextField, Button, Badge } from "@renderer/components"; import { TextField, Button, Badge } from "@renderer/components";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -10,6 +10,7 @@ import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useToast } from "@renderer/hooks"; import { useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared"; import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
export function SettingsDownloadSources() { export function SettingsDownloadSources() {
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] = const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
@ -18,8 +19,9 @@ export function SettingsDownloadSources() {
const [isSyncingDownloadSources, setIsSyncingDownloadSources] = const [isSyncingDownloadSources, setIsSyncingDownloadSources] =
useState(false); useState(false);
const { t } = useTranslation("settings"); const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
const { t } = useTranslation("settings");
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const getDownloadSources = async () => { const getDownloadSources = async () => {
@ -32,6 +34,10 @@ export function SettingsDownloadSources() {
getDownloadSources(); getDownloadSources();
}, []); }, []);
useEffect(() => {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
const handleRemoveSource = async (id: number) => { const handleRemoveSource = async (id: number) => {
await window.electron.removeDownloadSource(id); await window.electron.removeDownloadSource(id);
showSuccessToast(t("removed_download_source")); showSuccessToast(t("removed_download_source"));
@ -63,11 +69,16 @@ export function SettingsDownloadSources() {
[DownloadSourceStatus.Errored]: t("download_source_errored"), [DownloadSourceStatus.Errored]: t("download_source_errored"),
}; };
const handleModalClose = () => {
clearSourceUrl();
setShowAddDownloadSourceModal(false);
};
return ( return (
<> <>
<AddDownloadSourceModal <AddDownloadSourceModal
visible={showAddDownloadSourceModal} visible={showAddDownloadSourceModal}
onClose={() => setShowAddDownloadSourceModal(false)} onClose={handleModalClose}
onAddDownloadSource={handleAddDownloadSource} onAddDownloadSource={handleAddDownloadSource}
/> />

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { import {
@ -8,27 +8,24 @@ import {
SelectField, SelectField,
} from "@renderer/components"; } from "@renderer/components";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { UserPreferences } from "@types";
import { useAppSelector } from "@renderer/hooks"; import { useAppSelector } from "@renderer/hooks";
import { changeLanguage } from "i18next"; import { changeLanguage } from "i18next";
import * as languageResources from "@locales"; import * as languageResources from "@locales";
import { orderBy } from "lodash-es"; import { orderBy } from "lodash-es";
import { settingsContext } from "@renderer/context";
interface LanguageOption { interface LanguageOption {
option: string; option: string;
nativeName: string; nativeName: string;
} }
export interface SettingsGeneralProps { export function SettingsGeneral() {
updateUserPreferences: (values: Partial<UserPreferences>) => void;
}
export function SettingsGeneral({
updateUserPreferences,
}: SettingsGeneralProps) {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const { updateUserPreferences } = useContext(settingsContext);
const userPreferences = useAppSelector( const userPreferences = useAppSelector(
(state) => state.userPreferences.value (state) => state.userPreferences.value
); );

View File

@ -1,26 +1,23 @@
import { useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Button, CheckboxField, Link, TextField } from "@renderer/components"; import { Button, CheckboxField, Link, TextField } from "@renderer/components";
import * as styles from "./settings-real-debrid.css"; import * as styles from "./settings-real-debrid.css";
import type { UserPreferences } from "@types";
import { useAppSelector, useToast } from "@renderer/hooks"; import { useAppSelector, useToast } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
const REAL_DEBRID_API_TOKEN_URL = "https://real-debrid.com/apitoken"; const REAL_DEBRID_API_TOKEN_URL = "https://real-debrid.com/apitoken";
export interface SettingsRealDebridProps { export function SettingsRealDebrid() {
updateUserPreferences: (values: Partial<UserPreferences>) => void;
}
export function SettingsRealDebrid({
updateUserPreferences,
}: SettingsRealDebridProps) {
const userPreferences = useAppSelector( const userPreferences = useAppSelector(
(state) => state.userPreferences.value (state) => state.userPreferences.value
); );
const { updateUserPreferences } = useContext(settingsContext);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({ const [form, setForm] = useState({
useRealDebrid: false, useRealDebrid: false,

View File

@ -1,21 +1,20 @@
import { useState } from "react";
import { Button } from "@renderer/components"; import { Button } from "@renderer/components";
import * as styles from "./settings.css"; import * as styles from "./settings.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserPreferences } from "@types";
import { SettingsRealDebrid } from "./settings-real-debrid"; import { SettingsRealDebrid } from "./settings-real-debrid";
import { SettingsGeneral } from "./settings-general"; import { SettingsGeneral } from "./settings-general";
import { SettingsBehavior } from "./settings-behavior"; import { SettingsBehavior } from "./settings-behavior";
import { useAppDispatch } from "@renderer/hooks";
import { setUserPreferences } from "@renderer/features";
import { SettingsDownloadSources } from "./settings-download-sources"; import { SettingsDownloadSources } from "./settings-download-sources";
import {
SettingsContextConsumer,
SettingsContextProvider,
} from "@renderer/context";
export function Settings() { export function Settings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const dispatch = useAppDispatch();
const categories = [ const categories = [
t("general"), t("general"),
t("behavior"), t("behavior"),
@ -23,37 +22,24 @@ export function Settings() {
"Real-Debrid", "Real-Debrid",
]; ];
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0); return (
<SettingsContextProvider>
const handleUpdateUserPreferences = async ( <SettingsContextConsumer>
values: Partial<UserPreferences> {({ currentCategoryIndex, setCurrentCategoryIndex }) => {
) => {
await window.electron.updateUserPreferences(values);
window.electron.getUserPreferences().then((userPreferences) => {
dispatch(setUserPreferences(userPreferences));
});
};
const renderCategory = () => { const renderCategory = () => {
if (currentCategoryIndex === 0) { if (currentCategoryIndex === 0) {
return ( return <SettingsGeneral />;
<SettingsGeneral updateUserPreferences={handleUpdateUserPreferences} />
);
} }
if (currentCategoryIndex === 1) { if (currentCategoryIndex === 1) {
return ( return <SettingsBehavior />;
<SettingsBehavior updateUserPreferences={handleUpdateUserPreferences} />
);
} }
if (currentCategoryIndex === 2) { if (currentCategoryIndex === 2) {
return <SettingsDownloadSources />; return <SettingsDownloadSources />;
} }
return ( return <SettingsRealDebrid />;
<SettingsRealDebrid updateUserPreferences={handleUpdateUserPreferences} />
);
}; };
return ( return (
@ -63,7 +49,9 @@ export function Settings() {
{categories.map((category, index) => ( {categories.map((category, index) => (
<Button <Button
key={category} key={category}
theme={currentCategoryIndex === index ? "primary" : "outline"} theme={
currentCategoryIndex === index ? "primary" : "outline"
}
onClick={() => setCurrentCategoryIndex(index)} onClick={() => setCurrentCategoryIndex(index)}
> >
{category} {category}
@ -76,4 +64,8 @@ export function Settings() {
</div> </div>
</section> </section>
); );
}}
</SettingsContextConsumer>
</SettingsContextProvider>
);
} }