feat: deeplink handler for install ext from store (#860)

Co-authored-by: rain9 <15911122312@163.com>
Co-authored-by: ayang <473033518@qq.com>
This commit is contained in:
SteveLauC
2025-08-05 18:08:00 +08:00
committed by GitHub
parent ee75f0d119
commit 783cb73b29
15 changed files with 375 additions and 126 deletions

View File

@@ -83,5 +83,6 @@
"i18n-ally.keystyle": "nested",
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false
"editor.detectIndentation": false,
"i18n-ally.displayLanguage": "zh"
}

View File

@@ -69,6 +69,7 @@
"updater:default",
"windows-version:default",
"log:default",
"opener:default"
"opener:default",
"core:window:allow-unminimize"
]
}

View File

@@ -19,6 +19,7 @@ use crate::extension::third_party::install::filter_out_incompatible_sub_extensio
use crate::server::http_client::HttpClient;
use crate::util::platform::Platform;
use async_trait::async_trait;
use http::Method;
use reqwest::StatusCode;
use serde_json::Map as JsonObject;
use serde_json::Value as Json;
@@ -172,6 +173,52 @@ pub(crate) async fn search_extension(
Ok(extensions)
}
#[tauri::command]
pub(crate) async fn extension_detail(
id: String,
) -> Result<Option<JsonObject<String, Json>>, String> {
let url = format!("http://dev.infini.cloud:27200/store/extension/{}", id);
let response =
HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None).await?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
let response_dbg_str = format!("{:?}", response);
// The response of an ES style GET request
let mut response: JsonObject<String, Json> = response.json().await.unwrap_or_else(|_e| {
panic!(
"response body of [/store/extension/<ID>] is not a JSON object, response [{:?}]",
response_dbg_str
)
});
let source_json = response.remove("_source").unwrap_or_else(|| {
panic!("field [_source] not found in the JSON returned from [/store/extension/<ID>]")
});
let mut source_obj = match source_json {
Json::Object(obj) => obj,
_ => panic!(
"field [_source] should be a JSON object, but it is not, value: [{}]",
source_json
),
};
let developer_id = match &source_obj["developer"]["id"] {
Json::String(dev) => dev,
_ => {
panic!(
"field [_source.developer.id] should be a string, but it is not, value: [{}]",
source_obj["developer"]["id"]
)
}
};
let installed = is_extension_installed(developer_id, &id).await;
source_obj.insert("installed".to_string(), Json::Bool(installed));
Ok(Some(source_obj))
}
#[tauri::command]
pub(crate) async fn install_extension_from_store(
tauri_app_handle: AppHandle,
@@ -250,21 +297,32 @@ pub(crate) async fn install_extension_from_store(
e
);
});
let developer_id = extension.developer.clone().expect("developer has been set");
drop(plugin_json);
general_check(&extension)?;
let current_platform = Platform::current();
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) {
return Err("this extension is not compatible with your OS".into());
}
}
if is_extension_installed(&developer_id, &id).await {
return Err("Extension already installed.".into());
}
// Extension is compatible with current platform, but it could contain sub
// extensions that are not, filter them out.
filter_out_incompatible_sub_extensions(&mut extension, Platform::current());
filter_out_incompatible_sub_extensions(&mut extension, current_platform);
// Write extension files to the extension directory
let developer = extension.developer.clone().unwrap_or_default();
let extension_id = extension.id.clone();
let extension_directory = {
let mut path = get_third_party_extension_directory(&tauri_app_handle);
path.push(developer);
path.push(developer_id);
path.push(extension_id.as_str());
path
};

View File

@@ -161,6 +161,7 @@ pub fn run() {
extension::unregister_extension_hotkey,
extension::is_extension_enabled,
extension::third_party::install::store::search_extension,
extension::third_party::install::store::extension_detail,
extension::third_party::install::store::install_extension_from_store,
extension::third_party::install::local_extension::install_local_extension,
extension::third_party::uninstall_extension,

View File

@@ -13,11 +13,20 @@ export async function getCurrentWindowService() {
: currentService;
}
export async function setCurrentWindowService(service: any) {
const windowLabel = await platformAdapter.getCurrentWindowLabel();
export async function setCurrentWindowService(
service: any,
isAll?: boolean
) {
const { setCurrentService, setCloudSelectService } =
useConnectStore.getState();
// all refresh logout
if (isAll) {
setCloudSelectService(service);
setCurrentService(service);
return;
}
// current refresh
const windowLabel = await platformAdapter.getCurrentWindowLabel();
return windowLabel === SETTINGS_WINDOW_LABEL
? setCloudSelectService(service)
: setCurrentService(service);
@@ -35,7 +44,7 @@ export async function handleLogout(serverId?: string) {
// Update the status first
setIsCurrentLogin(false);
if (service?.id === id) {
await setCurrentWindowService({ ...service, profile: null });
await setCurrentWindowService({ ...service, profile: null }, true);
}
const updatedServerList = serverList.map((server) =>
server.id === id ? { ...server, profile: null } : server

View File

@@ -68,11 +68,12 @@ export default function Cloud() {
}, [serverList, errors, cloudSelectService]);
const refreshClick = useCallback(
async (id: string) => {
async (id: string, callback?: () => void) => {
setRefreshLoading(true);
await platformAdapter.commands("refresh_coco_server_info", id);
await refreshServerList();
setRefreshLoading(false);
callback && callback();
},
[refreshServerList]
);

View File

@@ -2,11 +2,6 @@ import { FC, memo, useCallback, useEffect, useState } from "react";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from "uuid";
import {
getCurrent as getCurrentDeepLinkUrls,
onOpenUrl,
} from "@tauri-apps/plugin-deep-link";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { UserProfile } from "./UserProfile";
import { OpenURLWithBrowser } from "@/utils";
@@ -18,19 +13,21 @@ import { useServers } from "@/hooks/useServers";
interface ServiceAuthProps {
setRefreshLoading: (loading: boolean) => void;
refreshClick: (id: string) => void;
refreshClick: (id: string, callback?: () => void) => void;
}
const ServiceAuth = memo(
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
const { t } = useTranslation();
const language = useAppStore((state) => state.language);
const addError = useAppStore((state) => state.addError);
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
const addError = useAppStore((state) => state.addError);
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
const cloudSelectService = useConnectStore(
(state) => state.cloudSelectService
);
const { logoutServer } = useServers();
@@ -64,100 +61,25 @@ const ServiceAuth = memo(
[logoutServer]
);
const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => {
if (!code || !serverId) {
addError("No authorization code received");
return;
}
try {
console.log("Handling OAuth callback:", { code, serverId });
await platformAdapter.commands("handle_sso_callback", {
serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code,
});
if (serverId != null) {
refreshClick(serverId);
}
getCurrentWindow().setFocus();
} catch (e) {
console.error("Sign in failed:", e);
} finally {
setLoading(false);
}
},
[ssoRequestID]
);
const handleUrl = (url: string) => {
try {
const urlObject = new URL(url.trim());
console.log("handle urlObject:", urlObject);
// pass request_id and check with local, if the request_id are same, then continue
const reqId = urlObject.searchParams.get("request_id");
const code = urlObject.searchParams.get("code");
if (reqId != ssoRequestID) {
console.log("Request ID not matched, skip");
addError("Request ID not matched, skip");
return;
}
const serverId = cloudSelectService?.id;
handleOAuthCallback(code, serverId);
} catch (err) {
console.error("Failed to parse URL:", err);
addError("Invalid URL format: " + err);
}
};
// Fetch the initial deep link intent
// handle oauth success event
useEffect(() => {
// Function to handle pasted URL
const handlePaste = (event: any) => {
const pastedText = event.clipboardData.getData("text").trim();
console.log("handle paste text:", pastedText);
if (isValidCallbackUrl(pastedText)) {
// Handle the URL as if it's a deep link
console.log("handle callback on paste:", pastedText);
handleUrl(pastedText);
}
};
// Function to check if the pasted URL is valid for our deep link scheme
const isValidCallbackUrl = (url: string) => {
return url && url.startsWith("coco://oauth_callback");
};
// Adding event listener for paste events
document.addEventListener("paste", handlePaste);
getCurrentDeepLinkUrls()
.then((urls) => {
console.log("URLs:", urls);
if (urls && urls.length > 0) {
if (isValidCallbackUrl(urls[0].trim())) {
handleUrl(urls[0]);
}
const unlistenOAuth = platformAdapter.listenEvent(
"oauth_success",
(event) => {
const { serverId } = event.payload;
if (serverId) {
refreshClick(serverId, () => {
setLoading(false);
});
addError(language === "zh" ? "登录成功" : "Login Success", "info");
}
})
.catch((err) => {
console.error("Failed to get initial URLs:", err);
addError("Failed to get initial URLs: " + err);
});
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
}
);
return () => {
unlisten.then((fn) => fn());
document.removeEventListener("paste", handlePaste);
unlistenOAuth.then((fn) => fn());
};
}, [ssoRequestID]);
}, [refreshClick]);
useEffect(() => {
setLoading(false);
@@ -214,7 +136,9 @@ const ServiceAuth = memo(
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(cloudSelectService?.provider?.privacy_policy)
OpenURLWithBrowser(
cloudSelectService?.provider?.privacy_policy
)
}
>
{t("cloud.privacyPolicy")}

View File

@@ -1,5 +1,5 @@
import { useAsyncEffect, useDebounce, useKeyPress, useUnmount } from "ahooks";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { CircleCheck, FolderDown, Loader } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
@@ -60,7 +60,7 @@ export interface SearchExtensionItem {
views: number;
};
checksum: string;
installed: boolean;
installed?: boolean;
commands?: Array<{
type: string;
name: string;
@@ -73,7 +73,7 @@ export interface SearchExtensionItem {
}>;
}
const ExtensionStore = () => {
const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
const {
searchValue,
selectedExtension,
@@ -107,7 +107,26 @@ const ExtensionStore = () => {
};
}, [selectedExtension]);
const handleExtensionDetail = useCallback(async () => {
try {
const detail = await platformAdapter.invokeBackend<SearchExtensionItem>(
"extension_detail",
{
id: extensionId,
}
);
setSelectedExtension(detail);
setVisibleExtensionDetail(true);
} catch (error) {
addError(String(error));
}
}, [extensionId, installingExtensions]);
useAsyncEffect(async () => {
if (extensionId) {
return handleExtensionDetail();
}
const result = await platformAdapter.invokeBackend<SearchExtensionItem[]>(
"search_extension",
{
@@ -125,7 +144,7 @@ const ExtensionStore = () => {
setList(result ?? []);
setSelectedExtension(result?.[0]);
}, [debouncedSearchValue]);
}, [debouncedSearchValue, extensionId]);
useUnmount(() => {
setSelectedExtension(void 0);

View File

@@ -1,4 +1,4 @@
import { useEffect, memo, useRef } from "react";
import { useEffect, memo, useRef, useCallback, useState } from "react";
import DropdownList from "./DropdownList";
import { SearchResults } from "@/components/Search/SearchResults";
@@ -36,6 +36,8 @@ const SearchResultsPanel = memo<{
performSearch,
} = searchState;
const [extensionId, setExtensionId] = useState<string>();
useEffect(() => {
if (!isChatMode && input) {
performSearch(input);
@@ -58,26 +60,63 @@ const SearchResultsPanel = memo<{
}
}, [selectedSearchContent]);
const handleOpenExtensionStore = useCallback(() => {
platformAdapter.showWindow();
changeMode && changeMode(false);
if (visibleExtensionStore || visibleExtensionDetail) return;
changeInput("");
setSearchValue("");
setVisibleExtensionStore(true);
}, [
changeMode,
visibleExtensionStore,
visibleExtensionDetail,
changeInput,
setSearchValue,
setVisibleExtensionStore,
]);
useEffect(() => {
const unlisten = platformAdapter.listenEvent("open-extension-store", () => {
platformAdapter.showWindow();
changeMode && changeMode(false);
const unlisten = platformAdapter.listenEvent(
"open-extension-store",
handleOpenExtensionStore
);
const unlisten_install = platformAdapter.listenEvent(
"extension_install_success",
(event) => {
const { extensionId } = event.payload;
if (visibleExtensionStore || visibleExtensionDetail) return;
changeInput("");
setSearchValue("");
setVisibleExtensionStore(true);
});
setExtensionId(extensionId);
}
);
return () => {
unlisten.then((fn) => {
fn();
});
unlisten_install.then((fn) => {
fn();
});
};
}, [visibleExtensionStore, visibleExtensionDetail]);
}, [handleOpenExtensionStore]);
if (visibleExtensionStore) return <ExtensionStore />;
useEffect(() => {
if (visibleExtensionDetail) return;
setExtensionId(void 0);
}, [visibleExtensionDetail]);
useEffect(() => {
if (!extensionId) return;
handleOpenExtensionStore();
}, [extensionId]);
if (visibleExtensionStore) {
return <ExtensionStore extensionId={extensionId} />;
}
if (goAskAi) return <AskAi isChatMode={isChatMode} />;
if (suggests.length === 0) return <NoResults />;

View File

@@ -0,0 +1,183 @@
import { useCallback, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
getCurrent as getCurrentDeepLinkUrls,
onOpenUrl,
} from "@tauri-apps/plugin-deep-link";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import { useTranslation } from "react-i18next";
import { MAIN_WINDOW_LABEL } from "@/constants";
import { useAsyncEffect, useEventListener } from "ahooks";
export interface DeepLinkHandler {
pattern: string;
handler: (url: URL) => Promise<void> | void;
}
export function useDeepLinkManager() {
const addError = useAppStore((state) => state.addError);
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const cloudSelectService = useConnectStore(
(state) => state.cloudSelectService
);
const { t } = useTranslation();
// handle oauth callback
const handleOAuthCallback = useCallback(
async (url: URL) => {
try {
const reqId = url.searchParams.get("request_id");
const code = url.searchParams.get("code");
if (reqId !== ssoRequestID) {
console.log("Request ID not matched, skip");
addError("Request ID not matched, skip");
return;
}
const serverId = cloudSelectService?.id;
if (!code || !serverId) {
addError("No authorization code received");
return;
}
console.log("Handling OAuth callback:", { code, serverId });
await platformAdapter.commands("handle_sso_callback", {
serverId: serverId,
requestId: ssoRequestID,
code: code,
});
// trigger oauth success event
platformAdapter.emitEvent("oauth_success", { serverId });
getCurrentWindow().setFocus();
} catch (err) {
console.error("Failed to parse OAuth callback URL:", err);
addError("Invalid OAuth callback URL format: " + err);
}
},
[ssoRequestID, cloudSelectService, addError]
);
// handle install extension from store
const handleInstallExtension = useCallback(async (url: URL) => {
const extensionId = url.searchParams.get("id");
if (!extensionId) {
return console.warn(
'received an invalid install_extension_from_store deeplink, missing argument "id"'
);
}
try {
await platformAdapter.showWindow();
await invoke("install_extension_from_store", { id: extensionId });
// trigger extension install success event
platformAdapter.emitEvent("extension_install_success", { extensionId });
addError(t("deepLink.extensionInstallSuccessfully"), "info");
console.log("Extension installed successfully:", extensionId);
} catch (error) {
addError(String(error));
}
}, []);
// handle deep link
const handlers: DeepLinkHandler[] = [
{
pattern: "oauth_callback",
handler: handleOAuthCallback,
},
{
pattern: "install_extension_from_store",
handler: async (url) => {
const windowLabel = await platformAdapter.getCurrentWindowLabel();
if (windowLabel !== MAIN_WINDOW_LABEL) return;
handleInstallExtension(url);
},
},
];
// handle deep link
const handleUrl = useCallback(
(url: string) => {
console.debug("handling deeplink URL", url);
try {
const urlObject = new URL(url.trim());
const deeplinkIdentifier = urlObject.hostname;
// find handler by pattern
const handler = handlers.find((h) => h.pattern === deeplinkIdentifier);
if (handler) {
handler.handler(urlObject);
} else {
console.error("Unknown deep link:", url);
addError("Unknown deep link: " + url);
}
} catch (err) {
console.error("Failed to parse URL:", err);
addError("Invalid URL format: " + err);
}
},
[handlers]
);
// handle paste text
const handlePaste = useCallback(
(event: ClipboardEvent) => {
const pastedText = event.clipboardData?.getData("text")?.trim();
console.log("handle paste text:", pastedText);
// coco://oauth_callback
if (pastedText && pastedText.startsWith("coco://oauth_callback")) {
console.log("handle deeplink on paste:", pastedText);
handleUrl(pastedText);
}
},
[handleUrl]
);
// get initial deep link
useAsyncEffect(async () => {
try {
const urls = await getCurrentDeepLinkUrls();
console.log("Initial DeepLinkUrls:", urls);
if (urls && urls.length > 0) {
handleUrl(urls[0]);
}
} catch (error) {
addError("Failed to get initial URLs: " + error);
}
}, []);
// handle deep link on paste
useEffect(() => {
// handle new deep link
const unlisten = onOpenUrl((urls) => {
console.log("onOpenUrl urls", urls);
handleUrl(urls[0]);
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
// add paste event listener
useEventListener("paste", handlePaste);
return {
handleUrl,
};
}

View File

@@ -573,5 +573,8 @@
"cancel": "Cancel",
"delete": "Delete"
}
},
"deepLink": {
"extensionInstallSuccessfully": "Extension installed successfully."
}
}

View File

@@ -572,5 +572,8 @@
"cancel": "取消",
"delete": "删除"
}
},
"deepLink": {
"extensionInstallSuccessfully": "扩展安装成功。"
}
}

View File

@@ -21,6 +21,7 @@ import { useIconfontScript } from "@/hooks/useScript";
import { Extension } from "@/components/Settings/Extensions";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { useServers } from "@/hooks/useServers";
import { useDeepLinkManager } from '@/hooks/useDeepLinkManager';
export default function Layout() {
const location = useLocation();
@@ -31,6 +32,8 @@ export default function Layout() {
// init servers isTauri
useServers();
// init deep link manager
useDeepLinkManager();
const [langUpdated, setLangUpdated] = useState(false);

View File

@@ -45,6 +45,8 @@ export interface EventPayloads {
"chat-create-error": string;
[key: `synthesize-${string}`]: any;
"check-update": any;
"oauth_success": any;
"extension_install_success": any;
}
// Window operation interface

View File

@@ -39,7 +39,9 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
async showWindow() {
const window = await windowWrapper.getWebviewWindow();
return window?.show();
window?.show();
window?.unminimize();
return window?.setFocus();
},
async emitEvent(event, payload) {