Add Unsloth desktop deep links (#7560)

* Add Unsloth desktop deep links

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address deep-link review feedback

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wasim Yousef Said 2026-07-28 16:52:53 +02:00 committed by GitHub
commit 65b4d9d9e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 707 additions and 22 deletions

View file

@ -34,6 +34,7 @@
"@tanstack/react-virtual": "3.13.25",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-deep-link": "2.4.9",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
@ -6451,6 +6452,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-deep-link": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz",
"integrity": "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",

View file

@ -44,6 +44,7 @@
"@tanstack/react-virtual": "3.13.25",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-deep-link": "2.4.9",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",

View file

@ -15,6 +15,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { WebUpdateBanner } from "@/components/web/update-banner";
import { fetchDeviceType } from "@/config/env";
import { getTauriAuthFailure, tauriAutoAuth } from "@/features/auth";
import { DeepLinkHandler } from "@/features/deep-links";
import { DownloadManagerPanel } from "@/features/hub/download-manager";
import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain";
import {
@ -500,6 +501,7 @@ export function AppProvider({ children }: AppProviderProps) {
<MotionConfig reducedMotion={REDUCED_MOTION_MAP[reduceMotion]}>
<TooltipProvider>
<AppearanceCustomizationEffect />
<DeepLinkHandler />
<TauriWrapper>{children}</TauriWrapper>
<Toaster
position="top-right"

View file

@ -13,6 +13,9 @@ const ModelsPage = lazyRouteComponent(
export interface ModelsSearch {
tab?: "discover" | "downloaded";
model?: string;
file?: string;
intent?: number;
section?: "trending" | "latest" | "finetune";
kind?: "models" | "datasets";
}
@ -28,6 +31,18 @@ export const Route = createRoute({
if (raw === "discover" || raw === "downloaded") next.tab = raw;
const model = search.model;
if (typeof model === "string" && model.length > 0) next.model = model;
const file = search.file;
if (next.model && typeof file === "string" && file.length > 0)
next.file = file;
const intent = search.intent;
if (
next.file &&
typeof intent === "number" &&
Number.isSafeInteger(intent)
) {
next.intent = intent;
}
const section = search.section;
if (
section === "trending" ||

View file

@ -0,0 +1,92 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { isTauri } from "@/lib/api-base";
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { createDeepLinkIntentGate } from "./deep-link-intent";
import { parseUnslothDeepLink } from "./parse-deep-link";
const acceptIntent = createDeepLinkIntentGate(2_000);
async function restoreMainWindow(): Promise<void> {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const window = getCurrentWindow();
await window.show();
await window.unminimize();
await window.setFocus();
}
export function DeepLinkHandler() {
const navigate = useNavigate();
useEffect(() => {
if (!isTauri) return;
let disposed = false;
let receivedLiveIntent = false;
let unlisten: (() => void) | undefined;
const handleUrls = (urls: string[]): boolean => {
if (disposed) return false;
let hasValidIntent = false;
let intent: ReturnType<typeof parseUnslothDeepLink> = null;
let intentSequence: number | null = null;
for (const rawUrl of urls) {
const parsed = parseUnslothDeepLink(rawUrl);
if (!parsed) continue;
hasValidIntent = true;
const sequence = acceptIntent(parsed.model, parsed.file);
if (sequence !== null) {
intent = parsed;
intentSequence = sequence;
}
}
if (!intent || intentSequence === null) return hasValidIntent;
void restoreMainWindow().catch(() => undefined);
void navigate({
to: "/hub",
search: {
tab: "discover",
kind: "models",
model: intent.model,
file: intent.file,
intent: intentSequence,
},
});
return true;
};
async function subscribe() {
const { getCurrent, onOpenUrl } =
await import("@tauri-apps/plugin-deep-link");
if (disposed) return;
const cleanup = await onOpenUrl((urls) => {
if (handleUrls(urls)) receivedLiveIntent = true;
});
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
const currentUrls = await getCurrent();
if (currentUrls && !receivedLiveIntent) handleUrls(currentUrls);
}
void subscribe().catch(() => undefined);
return () => {
disposed = true;
unlisten?.();
};
}, [navigate]);
return null;
}

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export function createDeepLinkIntentGate(
deduplicationWindowMs: number,
now: () => number = Date.now,
) {
let lastIntent: { key: string; handledAt: number } | null = null;
let sequence = 0;
return (model: string, file?: string): number | null => {
const handledAt = now();
const key = `${model}\0${file ?? ""}`;
if (
lastIntent?.key === key &&
handledAt - lastIntent.handledAt < deduplicationWindowMs
) {
return null;
}
lastIntent = { key, handledAt };
sequence += 1;
return sequence;
};
}

View file

@ -0,0 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { DeepLinkHandler } from "./deep-link-handler";

View file

@ -0,0 +1,101 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
const MAX_REPO_ID_SEGMENT_LENGTH = 96;
const MAX_GGUF_FILE_LENGTH = 512;
const REPO_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
function hasControlCharacters(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 0x1f || codePoint === 0x7f;
});
}
export interface UnslothDeepLinkIntent {
model: string;
file?: string;
}
function isValidRepoSegment(segment: string): boolean {
return (
segment.length <= MAX_REPO_ID_SEGMENT_LENGTH &&
REPO_SEGMENT.test(segment) &&
!segment.includes("--") &&
!segment.includes("..")
);
}
function isValidGgufFile(file: string): boolean {
if (
file.length === 0 ||
file.length > MAX_GGUF_FILE_LENGTH ||
file !== file.trim() ||
hasControlCharacters(file) ||
file.includes("\\") ||
file.startsWith("/") ||
!file.toLowerCase().endsWith(".gguf")
) {
return false;
}
return file
.split("/")
.every((segment) => segment !== "" && segment !== "." && segment !== "..");
}
export function parseUnslothDeepLink(
rawUrl: string,
): UnslothDeepLinkIntent | null {
const queryIndex = rawUrl.indexOf("?");
const target = queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
if (
target !== "unsloth://open_from_hf" &&
target !== "unsloth://open_from_hf/"
) {
return null;
}
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return null;
}
if (
url.protocol !== "unsloth:" ||
url.hostname !== "open_from_hf" ||
(url.pathname !== "" && url.pathname !== "/") ||
url.username !== "" ||
url.password !== "" ||
url.port !== "" ||
url.hash !== ""
) {
return null;
}
const keys = [...url.searchParams.keys()];
if (
keys.length < 1 ||
keys.length > 2 ||
!keys.includes("model") ||
new Set(keys).size !== keys.length ||
keys.some((key) => key !== "model" && key !== "file")
) {
return null;
}
const model = url.searchParams.get("model") ?? "";
const segments = model.split("/");
if (
model.endsWith(".git") ||
segments.length !== 2 ||
!segments.every(isValidRepoSegment)
) {
return null;
}
const file = url.searchParams.get("file");
if (file !== null && !isValidGgufFile(file)) return null;
return file === null ? { model } : { model, file };
}

View file

@ -15,6 +15,9 @@ export function DownloadSection({
canRun = true,
isActive,
activeQuant,
preferredGgufFile = null,
preferredGgufFileIntent = 0,
isLoadingThisModel,
gpuGb,
systemRamGb,
@ -35,6 +38,9 @@ export function DownloadSection({
canRun?: boolean;
isActive: boolean;
activeQuant: string | null;
preferredGgufFile?: string | null;
preferredGgufFileIntent?: number;
isLoadingThisModel: boolean;
gpuGb?: number;
systemRamGb?: number;
@ -46,12 +52,15 @@ export function DownloadSection({
onTrain?: () => void;
onChange?: () => void;
}) {
if (isGguf) {
if (isGguf || preferredGgufFile) {
return (
<GgufDownloadCard
repoId={repoId}
isActive={isActive}
activeQuant={activeQuant}
preferredFile={preferredGgufFile}
preferredFileIntent={preferredGgufFileIntent}
isLoadingThisModel={isLoadingThisModel}
gpuGb={gpuGb}
systemRamGb={systemRamGb}

View file

@ -57,6 +57,10 @@ import { useOnlineStatus } from "../hooks/use-online-status";
import { type GgufVariantDetail, deleteCachedModel } from "../inventory";
import { formatBytes } from "../lib/format";
import { type GgufFitClass, classifyGgufFit } from "../lib/gguf-fit";
import {
ggufFilenamesMatch,
ggufSelectionOverrideMatchesIntent,
} from "../lib/gguf-filename";
import {
ggufVariantDisplayLabel,
ggufVariantDownloadSizeBytes,
@ -540,6 +544,9 @@ export function GgufDownloadCard({
repoId,
isActive,
activeQuant,
preferredFile = null,
preferredFileIntent = 0,
isLoadingThisModel,
gpuGb,
systemRamGb,
@ -553,6 +560,9 @@ export function GgufDownloadCard({
repoId: string;
isActive: boolean;
activeQuant: string | null;
preferredFile?: string | null;
preferredFileIntent?: number;
isLoadingThisModel: boolean;
gpuGb?: number;
systemRamGb?: number;
@ -579,9 +589,25 @@ export function GgufDownloadCard({
repoId: string;
quant: string | null;
userPicked?: boolean;
preferredFile?: string | null;
preferredFileIntent?: number;
}>(() => ({ repoId, quant: null }));
const preferredQuant = preferredFile
? (variants?.find((variant) =>
ggufFilenamesMatch(variant.filename, preferredFile),
)?.quant ?? null)
: null;
const selectedQuantOverride =
selectedQuantState.repoId === repoId ? selectedQuantState.quant : null;
selectedQuantState.repoId === repoId &&
ggufSelectionOverrideMatchesIntent(
preferredFile,
preferredFileIntent,
selectedQuantState.preferredFile,
selectedQuantState.preferredFileIntent,
)
? selectedQuantState.quant
: preferredQuant;
const [open, setOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [updateTarget, setUpdateTarget] = useState<string | null>(null);
@ -732,10 +758,13 @@ export function GgufDownloadCard({
repoId,
quant,
userPicked: true,
preferredFile,
preferredFileIntent,
});
setOpen(false);
},
[repoId],
[preferredFile, preferredFileIntent, repoId],
);
const handleDeleteVariant = useCallback((quant: string) => {
setDeleteTarget(quant);

View file

@ -38,6 +38,11 @@ import {
deleteCachedModel,
} from "../inventory";
import { formatBytes } from "../lib/format";
import {
ggufFilenamesMatch,
ggufSelectionOverrideMatchesIntent,
} from "../lib/gguf-filename";
import {
ggufVariantDisplayLabel,
sortLocalGgufVariants,
@ -87,6 +92,9 @@ interface LocalOnDeviceCardProps {
activeGgufVariant?: string | null;
isLoading: boolean;
loadingPhase?: "downloading" | "starting";
preferredFile?: string | null;
preferredFileIntent?: number;
gpuGb?: number;
systemRamGb?: number;
unsupportedReason?: string | null;
@ -207,6 +215,9 @@ export function LocalOnDeviceCard({
activeGgufVariant = null,
isLoading,
loadingPhase,
preferredFile = null,
preferredFileIntent = 0,
gpuGb,
systemRamGb,
unsupportedReason,
@ -281,6 +292,8 @@ export function LocalOnDeviceCard({
const [selectedVariantState, setSelectedVariantState] = useState<{
key: string;
quant: string | null;
preferredFile?: string | null;
preferredFileIntent?: number;
}>(() => ({
key: variantKey,
quant: null,
@ -324,8 +337,21 @@ export function LocalOnDeviceCard({
systemRamGb,
],
);
const preferredQuant = preferredFile
? (variants?.find((variant) =>
ggufFilenamesMatch(variant.filename, preferredFile),
)?.quant ?? null)
: null;
const selectedVariantOverride =
selectedVariantState.key === variantKey ? selectedVariantState.quant : null;
selectedVariantState.key === variantKey &&
ggufSelectionOverrideMatchesIntent(
preferredFile,
preferredFileIntent,
selectedVariantState.preferredFile,
selectedVariantState.preferredFileIntent,
)
? selectedVariantState.quant
: preferredQuant;
const selectedQuant =
selectedVariantOverride &&
sortedVariants?.some((variant) =>
@ -502,6 +528,9 @@ export function LocalOnDeviceCard({
setSelectedVariantState({
key: variantKey,
quant: variant.quant,
preferredFile,
preferredFileIntent,
});
setVariantOpen(false);
}}

View file

@ -409,6 +409,9 @@ export const ModelInspector = memo(function ModelInspector({
model,
runtime,
actions,
preferredGgufFile = null,
preferredGgufFileIntent = 0,
isDataset = false,
metadataUnavailable = false,
selectionHiddenByFilters = false,
@ -417,6 +420,9 @@ export const ModelInspector = memo(function ModelInspector({
isDataset?: boolean;
metadataUnavailable?: boolean;
selectionHiddenByFilters?: boolean;
preferredGgufFile?: string | null;
preferredGgufFileIntent?: number;
runtime: ModelInspectorRuntime;
actions: ModelInspectorActions;
}) {
@ -693,6 +699,9 @@ export const ModelInspector = memo(function ModelInspector({
loadingPhase={loadingPhase}
gpuGb={gpuGb}
systemRamGb={systemRamGb}
preferredFile={preferredGgufFile}
preferredFileIntent={preferredGgufFileIntent}
unsupportedReason={
unslothSupport.status === "unsupported"
? (unslothSupport.reason ?? "Unsupported format")
@ -717,6 +726,9 @@ export const ModelInspector = memo(function ModelInspector({
canRun={canRunModel}
isActive={isActive}
activeQuant={isActive ? (activeGgufVariant ?? null) : null}
preferredGgufFile={preferredGgufFile}
preferredGgufFileIntent={preferredGgufFileIntent}
isLoadingThisModel={isLoadingThisModel}
gpuGb={gpuGb}
systemRamGb={systemRamGb}

View file

@ -339,7 +339,9 @@ export function ModelsPage() {
const deviceType = usePlatformStore((s) => s.deviceType);
const hubSearch = useSearch({ from: "/hub" });
const urlModel = hubSearch.model ?? null;
const preferredGgufFile = hubSearch.file ?? null;
const preferredGgufFileIntent = hubSearch.intent ?? 0;
const { selectModel, loadingModel, loadProgress, ejectModel } =
useChatModelRuntime();
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
@ -1031,7 +1033,7 @@ export function ModelsPage() {
setSelected(id);
void navigate({
to: "/hub",
search: (prev) => ({ ...prev, model: id }),
search: (prev) => ({ ...prev, model: id, file: undefined }),
});
},
[setSelected, navigate],
@ -1117,7 +1119,7 @@ export function ModelsPage() {
setSelected(firstId);
void navigate({
to: "/hub",
search: (prev) => ({ ...prev, model: firstId }),
search: (prev) => ({ ...prev, model: firstId, file: undefined }),
replace: true,
});
}, [
@ -1604,6 +1606,9 @@ export function ModelsPage() {
<div className="hub-canvas z-20 flex min-h-0 flex-col max-lg:absolute max-lg:inset-0 lg:relative lg:min-w-0 lg:flex-1">
<HubDetailView
model={selectedModel}
preferredGgufFile={preferredGgufFile}
preferredGgufFileIntent={preferredGgufFileIntent}
isDataset={isDatasetMode}
metadataUnavailable={metadataUnavailable}
selectionHiddenByFilters={selectionHiddenByFilters}
@ -1623,6 +1628,9 @@ export function ModelsPage() {
<div className="hub-canvas absolute inset-0 z-20 flex min-h-0 flex-col">
<HubDetailView
model={selectedModel}
preferredGgufFile={preferredGgufFile}
preferredGgufFileIntent={preferredGgufFileIntent}
isDataset={isDatasetMode}
metadataUnavailable={metadataUnavailable}
selectionHiddenByFilters={selectionHiddenByFilters}

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
const GGUF_SPLIT_SUFFIX = /-\d{3,}-of-\d{3,}(?=\.gguf$)/i;
function normalizeGgufFilename(filename: string): string {
return filename
.trim()
.replace(/\\/g, "/")
.replace(GGUF_SPLIT_SUFFIX, "")
.toLowerCase();
}
export function ggufFilenamesMatch(
left: string | null | undefined,
right: string | null | undefined,
): boolean {
if (!(left && right)) return false;
return normalizeGgufFilename(left) === normalizeGgufFilename(right);
}
export function ggufSelectionOverrideMatchesIntent(
preferredFile: string | null | undefined,
preferredFileIntent: number,
selectedPreferredFile: string | null | undefined,
selectedPreferredFileIntent: number | undefined,
): boolean {
return (
!preferredFile ||
(selectedPreferredFile === preferredFile &&
selectedPreferredFileIntent === preferredFileIntent)
);
}

View file

@ -558,6 +558,26 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@ -896,7 +916,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -945,6 +965,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@ -1111,7 +1140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -1738,6 +1767,12 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
@ -1941,7 +1976,7 @@ dependencies = [
"tokio",
"tower-service",
"tracing",
"windows-registry",
"windows-registry 0.6.1",
]
[[package]]
@ -2531,7 +2566,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -2944,6 +2979,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@ -2961,7 +3006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3824,6 +3869,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@ -3849,7 +3904,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3905,7 +3960,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4347,7 +4402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -4774,6 +4829,27 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry 0.5.3",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
@ -4876,6 +4952,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
@ -5014,7 +5091,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@ -5051,10 +5128,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5174,6 +5251,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@ -5460,7 +5546,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -5500,7 +5586,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -5590,6 +5676,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener",
@ -6069,7 +6156,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -6257,6 +6344,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-registry"
version = "0.6.1"

View file

@ -7,7 +7,8 @@ edition = "2021"
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-single-instance = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-deep-link = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -16,10 +16,12 @@
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-minimize",
"core:window:allow-unminimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:tray:default",
"process:default",
"deep-link:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",

View file

@ -0,0 +1,12 @@
[Desktop Entry]
Categories={{categories}}
{{#if comment}}
Comment={{comment}}
{{/if}}
Exec={{exec}} %u
StartupWMClass={{exec}}
Icon={{icon}}
Name={{name}}
Terminal=false
Type=Application
MimeType=x-scheme-handler/unsloth;

View file

@ -17,7 +17,7 @@ mod process;
mod update;
mod windows_job;
use log::info;
use log::{info, warn};
use process::new_backend_state;
use simplelog::{
CombinedLogger, Config, LevelFilter, SharedLogger, TermLogger, TerminalMode, WriteLogger,
@ -176,9 +176,11 @@ fn main() {
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
@ -234,6 +236,13 @@ fn main() {
has_saved_window_state,
])
.setup(|app| {
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
use tauri_plugin_deep_link::DeepLinkExt;
if let Err(error) = app.deep_link().register_all() {
warn!("Failed to register deep-link handlers: {error}");
}
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
setup_custom_titlebar(app)?;
setup_tray(app)?;

View file

@ -35,6 +35,11 @@
]
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["unsloth"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFBQzA4RjczODM0RjE1QjcKUldTM0ZVK0RjNC9BR2t4R0RVaFR5cTkyUlRVQ1FwaGV0Nk04eWNwWXBhZnlzalJydllmZm1QTS8K",
"endpoints": [
@ -80,6 +85,7 @@
"bundleMediaFramework": false
},
"deb": {
"desktopTemplate": "./linux/unsloth.desktop",
"postRemoveScript": "./linux/postremove.sh"
}
}