[features] UI Worker Extension, Troubleshooters, Quick Link (#21)

* perf: reduce desktop frontend bundle from 10 to 2MB

Use shiki fine-grained bundle, avoid bundling all languages and themes

* feat: add cross-page transition for ext store back button with gasp Flip

* refactor: move StoreListing.svelte in @kksh/ui back to desktop

I realized that StoreListing is a pure wrapper, all the interactions are done with props. Even if this component is later used in other projects, it either lacks flexibility or require more changes. So it's moved back to desktop as a regular +page.svelte

* feat: Add a bunch of builtin commands for app internal control

* feat: add system commands

* feat: add extensionsInstallDir var to +layout.ts, exposed to all pages

All pages won't need to get the path asynchronously, it's kind of like a global constant

* [feat] troubleshooters (#15)

* feat: add extension loading troubleshooter

* feat: add extension permission inspector

* feat: add extension window map troubleshooter (WIP)

* fix: unregister extension when window is closed

* Feature: Deep Link + Supabase OAuth + open extension in store with deep link (#16)

* feat(auth): add deep link and supabase auth

* fix(deep-link): fix some routing and reactive page rendering

* feat: implement supabase auth with pkce auth flow

* feat: add createTauriSyncStore factory function for creating sync svelte store

* Feature: Quick Link (#17)

* feat: add page for add quick link (not working yet)

* upgrade @kksh/svelte5

* fix: infinite recursive footer

* dep: add @kksh/svelte5 to ui package

* dep: add supabase-js

* dep: add @iconify/svelte

* style: modify StoreExtDetail width control

* fixed: UI for extension store detail

* feat: add page to create quick link

* feat: display quick links in cmd palette

* snapshot

* show queries in command input

* feat: quick link fully implemented

* refactor: format all with prettier

* feat: add icon picker for quick link adder

* fix: make invert for icon optional, caused many types to crash

* [Feature] Implement UI template worker command (#20)

* feat: add ui worker command loading code (not working yet)

* feat: add unocss

* feat: add-dev-extension page

* feat: implemented list view template

* feat: implement list view detail view width, add demo extension for dev

* fix: resize listview, add metadata component

* fix: metadata tag component  background color

* feat: implement boolean (checkbox), date fields for form template

* feat: support default, optional, placeholder for form fields

* feat: implemented form view Select Field

* feat: markdown view

* feat: fixed a markdown schema type error

* fix: markdown styling

* feat: implement action panel for UI worker template list view

* format: format all

* chore: bump desktop version

* fix: fix search term bind in list view
This commit is contained in:
Huakun Shen 2024-11-08 15:34:37 -05:00 committed by GitHub
parent a3dbdb02de
commit 383270c93a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
152 changed files with 6447 additions and 476 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
pnpm-lock.yaml linguist-generated=true
packages/tauri-plugins/jarvis/permissions/autogenerated linguist-generated=true

8
apps/desktop/app.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import type { AttributifyAttributes } from "@unocss/preset-attributify"
declare module "svelte/elements" {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-unused-vars
interface HTMLAttributes<T> extends AttributifyAttributes {}
}
export {}

View File

@ -1,6 +1,6 @@
{
"name": "@kksh/desktop",
"version": "0.1.9-beta.8",
"version": "0.1.10",
"description": "",
"type": "module",
"scripts": {
@ -15,6 +15,7 @@
"license": "MIT",
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@huakunshen/comlink": "^4.4.1",
"@kksh/extension": "workspace:*",
"@kksh/supabase": "workspace:*",
"@kksh/ui": "workspace:*",
@ -27,9 +28,11 @@
"lucide-svelte": "^0.454.0",
"lz-string": "^1.5.0",
"mode-watcher": "^0.4.1",
"semver": "^7.6.3",
"svelte-radix": "^2.0.1",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.20.0"
"sveltekit-superforms": "^2.20.0",
"uuid": "^11.0.2"
},
"devDependencies": {
"@kksh/types": "workspace:*",
@ -42,6 +45,8 @@
"@tailwindcss/typography": "^0.5.15",
"@tauri-apps/cli": "^2.0.4",
"@types/bun": "latest",
"@types/semver": "^7.5.8",
"@unocss/preset-attributify": "^0.64.0",
"autoprefixer": "^10.4.20",
"clsx": "^2.1.1",
"embla-carousel-svelte": "^8.3.1",
@ -52,6 +57,7 @@
"tailwindcss-animate": "^1.0.7",
"tslib": "^2.8.1",
"typescript": "^5.6.3",
"unocss": "^0.64.0",
"vaul-svelte": "^0.3.2",
"vite": "^5.4.10"
}

View File

@ -8,47 +8,14 @@ pub fn tauri_file_server(
extension_folder_path: PathBuf,
dist: Option<String>,
) -> tauri::http::Response<Vec<u8>> {
// let host = request.uri().host().unwrap();
// let host_parts: Vec<&str> = host.split(".").collect();
// if host_parts.len() != 3 {
// return tauri::http::Response::builder()
// .status(tauri::http::StatusCode::NOT_FOUND)
// .header("Access-Control-Allow-Origin", "*")
// .body("Invalid Host".as_bytes().to_vec())
// .unwrap();
// }
// expect 3 parts, ext_identifier, dist and ext_type
// let ext_identifier = host_parts[0];
// let dist = host_parts[1];
// let ext_type = host_parts[2]; // ext or dev-ext
// let app_state = app.state::<tauri_plugin_jarvis::model::app_state::AppState>();
// let app_state: tauri:State<tauri_plugin_jarvis::model::app_state::AppState> = app.state();
// let extension_folder_path: Option<PathBuf> = match ext_type {
// "ext" => Some(app_state.extension_path.lock().unwrap().clone()),
// "dev-ext" => app_state.dev_extension_path.lock().unwrap().clone(),
// _ => None,
// };
// let extension_folder_path = match extension_folder_path {
// Some(path) => path,
// None => {
// return tauri::http::Response::builder()
// .status(tauri::http::StatusCode::NOT_FOUND)
// .header("Access-Control-Allow-Origin", "*")
// .body("Extension Folder Not Found".as_bytes().to_vec())
// .unwrap()
// }
// };
println!("dist: {:?}", dist);
let path = &request.uri().path()[1..]; // skip the first /
let path = urlencoding::decode(path).unwrap().to_string();
let mut url_file_path = extension_folder_path;
// .join(ext_identifier)
match dist {
Some(dist) => url_file_path = url_file_path.join(dist),
None => {}
}
url_file_path = url_file_path.join(path);
println!("url_file_path: {:?}", url_file_path);
// check if it's file or directory, if file and exist, return file, if directory, return index.html, if neither, check .html
if url_file_path.is_file() {
// println!("1st case url_file_path: {:?}", url_file_path);

View File

@ -1,7 +1,12 @@
import { appState } from "@/stores"
import { appConfig, appState, auth } from "@/stores"
import { checkUpdateAndInstall } from "@/utils/updater"
import type { BuiltinCmd } from "@kksh/ui/types"
import { dev } from "$app/environment"
import { getVersion } from "@tauri-apps/api/app"
import { WebviewWindow } from "@tauri-apps/api/webviewWindow"
import { exit } from "@tauri-apps/plugin-process"
import { goto } from "$app/navigation"
import { toast } from "svelte-sonner"
import { v4 as uuidv4 } from "uuid"
export const builtinCmds: BuiltinCmd[] = [
{
@ -13,58 +18,59 @@ export const builtinCmds: BuiltinCmd[] = [
goto("/extension/store")
}
},
// {
// name: "Sign In",
// iconifyIcon: "mdi:login-variant",
// description: "",
// function: async () => {
// goto("/auth")
// }
// },
// {
// name: "Sign Out",
// iconifyIcon: "mdi:logout-variant",
// description: "",
// function: async () => {
// const supabase = useSupabaseClient()
// supabase.auth.signOut()
// }
// },
// {
// name: "Show Draggable Area",
// iconifyIcon: "mingcute:move-fill",
// description: "",
// function: async () => {
// // select all html elements with attribute data-tauri-drag-region
// const elements = document.querySelectorAll("[data-tauri-drag-region]")
// elements.forEach((el) => {
// el.classList.add("bg-red-500/30")
// })
// setTimeout(() => {
// elements.forEach((el) => {
// el.classList.remove("bg-red-500/30")
// })
// }, 2_000)
// }
// },
// {
// name: "Add Dev Extension",
// iconifyIcon: "lineicons:dev",
// description: "",
// function: async () => {
// const appStateStore = useAppStateStore()
// appStateStore.setSearchTermSync("")
// goto("/add-dev-ext")
// }
// },
// {
// name: "Kunkun Version",
// iconifyIcon: "stash:version-solid",
// description: "",
// function: async () => {
// toast.success(`Kunkun Version: ${await getVersion()}`)
// }
// },
{
name: "Sign In",
iconifyIcon: "mdi:login-variant",
description: "",
function: async () => {
goto("/auth")
}
},
{
name: "Sign Out",
iconifyIcon: "mdi:logout-variant",
description: "",
function: async () => {
auth
.signOut()
.then(() => toast.success("Signed out"))
.catch((err) => toast.error("Failed to sign out: ", { description: err.message }))
}
},
{
name: "Show Draggable Area",
iconifyIcon: "mingcute:move-fill",
description: "",
function: async () => {
// select all html elements with attribute data-tauri-drag-region
const elements = document.querySelectorAll("[data-tauri-drag-region]")
elements.forEach((el) => {
el.classList.add("bg-red-500/30")
})
setTimeout(() => {
elements.forEach((el) => {
el.classList.remove("bg-red-500/30")
})
}, 2_000)
}
},
{
name: "Add Dev Extension",
iconifyIcon: "lineicons:dev",
description: "",
function: async () => {
appState.clearSearchTerm()
goto("/settings/add-dev-extension")
}
},
{
name: "Kunkun Version",
iconifyIcon: "stash:version-solid",
description: "",
function: async () => {
toast.success(`Kunkun Version: ${await getVersion()}`)
}
},
{
name: "Set Dev Extension Path",
iconifyIcon: "lineicons:dev",
@ -75,52 +81,48 @@ export const builtinCmds: BuiltinCmd[] = [
goto("/settings/set-dev-ext-path")
}
},
// {
// name: "Extension Window Troubleshooter",
// iconifyIcon: "material-symbols:window-outline",
// description: "",
// function: async () => {
// const appStateStore = useAppStateStore()
// appStateStore.setSearchTermSync("")
// // goto("/window-troubleshooter")
// const winLabel = `main:window-troubleshooter-${uuidv4()}`
// console.log(winLabel)
// new WebviewWindow(winLabel, {
// url: "/window-troubleshooter",
// title: "Window Troubleshooter"
// })
// }
// },
// {
// name: "Extension Permission Inspector",
// iconifyIcon: "hugeicons:inspect-code",
// description: "",
// function: async () => {
// const appStateStore = useAppStateStore()
// appStateStore.setSearchTermSync("")
// goto("/ext-permission-inspector")
// }
// },
// {
// name: "Extension Loading Troubleshooter",
// iconifyIcon: "material-symbols:troubleshoot",
// description: "",
// function: async () => {
// const appStateStore = useAppStateStore()
// appStateStore.setSearchTermSync("")
// goto("/extension-load-troubleshooter")
// }
// },
// {
// name: "Create Quicklink",
// iconifyIcon: "material-symbols:link",
// description: "Create a Quicklink",
// function: async () => {
// const appStateStore = useAppStateStore()
// appStateStore.setSearchTermSync("")
// goto("/create-quicklink")
// }
// },
{
name: "Extension Window Troubleshooter",
iconifyIcon: "material-symbols:window-outline",
description: "",
function: async () => {
appState.clearSearchTerm()
// goto("/window-troubleshooter")
const winLabel = `main:extension-window-troubleshooter-${uuidv4()}`
console.log(winLabel)
new WebviewWindow(winLabel, {
url: "/troubleshooters/extension-window",
title: "Extension Window Troubleshooter"
})
}
},
{
name: "Extension Permission Inspector",
iconifyIcon: "hugeicons:inspect-code",
description: "",
function: async () => {
appState.clearSearchTerm()
goto("/extension/permission-inspector")
}
},
{
name: "Extension Loading Troubleshooter",
iconifyIcon: "material-symbols:troubleshoot",
description: "",
function: async () => {
appState.clearSearchTerm()
goto("/troubleshooters/extension-loading")
}
},
{
name: "Create Quicklink",
iconifyIcon: "material-symbols:link",
description: "Create a Quicklink",
function: async () => {
appState.clearSearchTerm()
goto("/extension/create-quick-link")
}
},
// {
// name: "Settings",
// iconifyIcon: "solar:settings-linear",
@ -143,30 +145,32 @@ export const builtinCmds: BuiltinCmd[] = [
// appStateStore.setSearchTermSync("")
// }
// },
// {
// name: "Check Update",
// iconifyIcon: "material-symbols:update",
// description: "Check for updates",
// function: async () => {
// checkUpdateAndInstall()
// }
// },
// {
// name: "Check Beta Update",
// iconifyIcon: "material-symbols:update",
// description: "Check for Beta updates",
// function: async () => {
// checkUpdateAndInstall(true)
// }
// },
// {
// name: "Reload",
// iconifyIcon: "tabler:reload",
// description: "Reload this page",
// function: async () => {
// location.reload()
// }
// },
{
name: "Check Update",
iconifyIcon: "material-symbols:update",
description: "Check for updates",
function: async () => {
checkUpdateAndInstall()
appState.clearSearchTerm()
}
},
{
name: "Check Beta Update",
iconifyIcon: "material-symbols:update",
description: "Check for Beta updates",
function: async () => {
checkUpdateAndInstall({ beta: true })
appState.clearSearchTerm()
}
},
{
name: "Reload",
iconifyIcon: "tabler:reload",
description: "Reload this page",
function: async () => {
location.reload()
}
},
{
name: "Dance",
iconifyIcon: "mdi:dance-pole",
@ -174,33 +178,43 @@ export const builtinCmds: BuiltinCmd[] = [
function: async () => {
goto("/dance")
}
},
{
name: "Quit Kunkun",
iconifyIcon: "emojione:cross-mark-button",
description: "Quit Kunkun",
function: async () => {
exit(0)
}
},
{
name: "Toggle Dev Extension HMR",
iconifyIcon: "ri:toggle-line",
description: "Load dev extensions from their dev server URLs",
function: async () => {
appConfig.update((config) => {
toast.success(`Dev Extension HMR toggled to: ${!config.hmr}`)
return {
...config,
hmr: !config.hmr
}
})
appState.clearSearchTerm()
}
},
{
name: "Toggle Hide On Blur",
iconifyIcon: "ri:toggle-line",
description: "Toggle Hide On Blur",
function: async () => {
appConfig.update((config) => {
toast.success(`"Hide on Blur" toggled to: ${!config.hideOnBlur}`)
return {
...config,
hideOnBlur: !config.hideOnBlur
}
})
appState.clearSearchTerm()
}
}
// {
// name: "Quit Kunkun",
// iconifyIcon: "emojione:cross-mark-button",
// description: "Quit Kunkun",
// function: async () => {
// exit(0)
// }
// },
// {
// name: "Toggle Dev Extension Live Load Mode",
// iconifyIcon: "ri:toggle-line",
// description: "Load dev extensions from their dev server URLs",
// function: async () => {
// toggleDevExtensionLiveLoadMode()
// }
// },
// {
// name: "Toggle Hide On Blur",
// iconifyIcon: "ri:toggle-line",
// description: "Toggle Hide On Blur",
// function: async () => {
// const appConfig = useAppConfigStore()
// appConfig.setHideOnBlur(!appConfig.hideOnBlur)
// const appStateStore = useAppStateStore()
// appStateStore.setSearchTermSync("")
// toast.success(`"Hide on Blur" toggled to: ${appConfig.hideOnBlur}`)
// }
// }
]

View File

@ -1,14 +1,12 @@
import { appState } from "@/stores"
import { winExtMap } from "@/stores/winExtMap"
import { trimSlash } from "@/utils/url"
import { constructExtensionSupportDir } from "@kksh/api"
import { CmdTypeEnum, CustomUiCmd, ExtPackageJsonExtra, TemplateUiCmd } from "@kksh/api/models"
import { CustomUiCmd, ExtPackageJsonExtra, TemplateUiCmd } from "@kksh/api/models"
import { launchNewExtWindow } from "@kksh/extension"
import { convertFileSrc } from "@tauri-apps/api/core"
import { WebviewWindow } from "@tauri-apps/api/webviewWindow"
import * as fs from "@tauri-apps/plugin-fs"
import { debug } from "@tauri-apps/plugin-log"
import { goto } from "$app/navigation"
import * as v from "valibot"
export async function createExtSupportDir(extPath: string) {
const extSupportDir = await constructExtensionSupportDir(extPath)
@ -23,7 +21,19 @@ export async function onTemplateUiCmdSelect(
{ isDev, hmr }: { isDev: boolean; hmr: boolean }
) {
await createExtSupportDir(ext.extPath)
console.log("onTemplateUiCmdSelect", ext, cmd, isDev, hmr)
// console.log("onTemplateUiCmdSelect", ext, cmd, isDev, hmr)
const url = `/extension/ui-worker?extPath=${encodeURIComponent(ext.extPath)}&cmdName=${encodeURIComponent(cmd.name)}`
if (cmd.window) {
const winLabel = await winExtMap.registerExtensionWithWindow({ extPath: ext.extPath })
const window = launchNewExtWindow(winLabel, url, cmd.window)
window.onCloseRequested(async (event) => {
await winExtMap.unregisterExtensionFromWindow(winLabel)
})
} else {
return winExtMap
.registerExtensionWithWindow({ windowLabel: "main", extPath: ext.extPath })
.then(() => goto(url))
}
}
export async function onCustomUiCmdSelect(
@ -31,7 +41,7 @@ export async function onCustomUiCmdSelect(
cmd: CustomUiCmd,
{ isDev, hmr }: { isDev: boolean; hmr: boolean }
) {
console.log("onCustomUiCmdSelect", ext, cmd, isDev, hmr)
// console.log("onCustomUiCmdSelect", ext, cmd, isDev, hmr)
await createExtSupportDir(ext.extPath)
let url = cmd.main
@ -48,14 +58,14 @@ export async function onCustomUiCmdSelect(
})
console.log("Launch new window, ", winLabel)
const window = launchNewExtWindow(winLabel, url2, cmd.window)
window.onCloseRequested(async (event) => {
await winExtMap.unregisterExtensionFromWindow(winLabel)
})
} else {
console.log("Launch main window")
return winExtMap
.registerExtensionWithWindow({
windowLabel: "main",
extPath: ext.extPath,
dist: cmd.dist
})
.registerExtensionWithWindow({ windowLabel: "main", extPath: ext.extPath, dist: cmd.dist })
.then(() => goto(url2))
}
appState.clearSearchTerm()
}

View File

@ -2,6 +2,7 @@ import { CmdTypeEnum, CustomUiCmd, ExtPackageJsonExtra, TemplateUiCmd } from "@k
import type { CommandLaunchers, OnExtCmdSelect } from "@kksh/ui/types"
import * as v from "valibot"
import { onCustomUiCmdSelect, onTemplateUiCmdSelect } from "./ext"
import { onQuickLinkSelect } from "./quick-links"
const onExtCmdSelect: OnExtCmdSelect = (
ext: ExtPackageJsonExtra,
@ -20,4 +21,4 @@ const onExtCmdSelect: OnExtCmdSelect = (
}
}
export const commandLaunchers = { onExtCmdSelect } satisfies CommandLaunchers
export const commandLaunchers = { onExtCmdSelect, onQuickLinkSelect } satisfies CommandLaunchers

View File

@ -0,0 +1,25 @@
import { appState } from "@/stores"
import type { CmdQuery, CmdValue } from "@kksh/ui/types"
import { open } from "tauri-plugin-shellx-api"
/**
* Given some link like https://google.com/search?q={argument}&query={query}
* Find {argument} and {query}
*/
export function findAllArgsInLink(link: string): string[] {
const regex = /\{([^}]+)\}/g
const matches = [...link.matchAll(regex)]
return matches.map((match) => match[1])
}
export function onQuickLinkSelect(quickLink: CmdValue, queries: CmdQuery[]) {
console.log(quickLink, queries)
let qlink = quickLink.data
for (const arg of queries) {
console.log(`replace all {${arg.name}} with ${arg.value}`)
qlink = qlink.replaceAll(`{${arg.name}}`, arg.value)
}
appState.clearSearchTerm()
console.log(qlink)
open(qlink)
}

View File

@ -0,0 +1,4 @@
import { getSystemCommands } from "@kksh/api/commands"
import type { SysCommand } from "@kksh/api/models"
export const systemCommands: SysCommand[] = getSystemCommands()

View File

@ -0,0 +1,38 @@
<script lang="ts">
import { listen, TauriEvent, type UnlistenFn } from "@tauri-apps/api/event"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { onDestroy, onMount, type Snippet } from "svelte"
let unlisteners: UnlistenFn[] = []
const {
children,
onEnter,
onDrop,
onCancelled,
onOver
}: {
children: Snippet
onEnter?: (event: any) => void
onDrop?: (event: any) => void
onCancelled?: (event: any) => void
onOver?: (event: any) => void
} = $props()
const appWin = getCurrentWebviewWindow()
onMount(async () => {
if (onEnter) await appWin.listen(TauriEvent.DRAG_ENTER, onEnter)
if (onDrop) await appWin.listen(TauriEvent.DRAG_DROP, onDrop)
if (onCancelled) await appWin.listen(TauriEvent.DRAG_LEAVE, onCancelled)
if (onOver) await appWin.listen(TauriEvent.DRAG_OVER, onOver)
})
onDestroy(() => {
for (const unlisten of unlisteners) {
unlisten()
}
})
</script>
<span>
{@render children()}
</span>

View File

@ -1,72 +0,0 @@
<!-- This file renders the main command palette, a list of commands -->
<!-- This is not placed in @kksh/ui because it depends on the app config and is very complex,
passing everything through props will be very complicated and hard to maintain.
-->
<script lang="ts">
import { devStoreExts, installedStoreExts } from "@/stores"
import type { ExtPackageJsonExtra } from "@kksh/api/models"
import { isExtPathInDev } from "@kksh/extension/utils"
import { Command } from "@kksh/svelte5"
import type { AppConfig, AppState } from "@kksh/types"
import {
BuiltinCmds,
CustomCommandInput,
ExtCmdsGroup,
GlobalCommandPaletteFooter
} from "@kksh/ui/main"
import type { BuiltinCmd, CommandLaunchers } from "@kksh/ui/types"
import { cn } from "@kksh/ui/utils"
import type { Writable } from "svelte/store"
const {
extensions,
appConfig,
class: className,
commandLaunchers,
appState,
builtinCmds
}: {
extensions: ExtPackageJsonExtra[]
appConfig: Writable<AppConfig>
class?: string
commandLaunchers: CommandLaunchers
appState: Writable<AppState>
builtinCmds: BuiltinCmd[]
} = $props()
</script>
<Command.Root
class={cn("rounded-lg border shadow-md", className)}
bind:value={$appState.highlightedCmd}
loop
>
<CustomCommandInput
autofocus
placeholder="Type a command or search..."
bind:value={$appState.searchTerm}
/>
<Command.List class="max-h-screen grow">
<Command.Empty data-tauri-drag-region>No results found.</Command.Empty>
<BuiltinCmds {builtinCmds} />
{#if $appConfig.extensionPath && $devStoreExts.length > 0}
<ExtCmdsGroup
extensions={$devStoreExts}
heading="Dev Extensions"
isDev={true}
onExtCmdSelect={commandLaunchers.onExtCmdSelect}
hmr={$appConfig.hmr}
/>
{/if}
{#if $appConfig.extensionPath && $installedStoreExts.length > 0}
<ExtCmdsGroup
extensions={$installedStoreExts}
heading="Extensions"
isDev={false}
hmr={false}
onExtCmdSelect={commandLaunchers.onExtCmdSelect}
/>
{/if}
<Command.Separator />
</Command.List>
<GlobalCommandPaletteFooter />
</Command.Root>

View File

@ -0,0 +1,149 @@
<script lang="ts">
import DragNDrop from "@/components/common/DragNDrop.svelte"
import DevExtPathForm from "@/components/standalone/settings/DevExtPathForm.svelte"
import { appConfig, extensions } from "@/stores"
import { goBackOnEscape } from "@/utils/key"
import { goBack } from "@/utils/route"
import { IconEnum } from "@kksh/api/models"
import * as extAPI from "@kksh/extension"
import { installFromNpmPackageName } from "@kksh/extension"
import { Button, Card } from "@kksh/svelte5"
import { cn } from "@kksh/svelte5/utils"
import { IconMultiplexer, Layouts, StrikeSeparator } from "@kksh/ui"
import { open as openFileSelector } from "@tauri-apps/plugin-dialog"
import * as fs from "@tauri-apps/plugin-fs"
import { enhance } from "$app/forms"
import { goto } from "$app/navigation"
import { ArrowLeftIcon } from "lucide-svelte"
import { toast } from "svelte-sonner"
import * as v from "valibot"
import InstallNpmPackageNameForm from "./install-npm-package-name-form.svelte"
import InstallTarballUrlForm from "./install-tarball-url-form.svelte"
let dragging = $state(false)
async function handleDragNDropInstall(paths: string[]) {
dragging = false
console.log(paths)
for (const path of paths) {
const stat = await fs.stat(path)
if (await stat.isDirectory) {
await extensions
.installDevExtensionDir(path)
.then((ext) => {
toast.success("Success", {
description: `Extension from ${ext.extPath} installed successfully`
})
})
.catch((err) => {
toast.warning("Failed to install extension", { description: err })
})
} else if (await stat.isFile) {
if (!$appConfig.devExtensionPath) {
toast.warning(
"Please set the dev extension path in the settings to install tarball extension"
)
continue
}
await extensions
.installTarball(path, $appConfig.devExtensionPath)
.then((ext) => {
toast.success("Success", {
description: `Extension from ${path} installed successfully`
})
})
.catch((err) => {
toast.warning("Failed to install extension", { description: err })
})
} else {
toast.warning(`Unsupported file type: ${path}`)
}
// await installDevExtensionDir(path)
}
}
async function pickExtFolders() {
const selected = await openFileSelector({
directory: true,
multiple: true // allow install multiple extensions at once
})
if (!selected) {
return toast.warning("No File Selected")
}
for (const dir of selected) {
await extensions
.installDevExtensionDir(dir)
.then((ext) => {
toast.success("Success", {
description: `Extension from ${ext.extPath} installed successfully`
})
})
.catch((err) => {
toast.warning("Failed to install extension", { description: err })
})
}
}
async function pickExtFiles() {
if (!$appConfig.devExtensionPath) {
toast.warning("Please set the dev extension path in the settings")
return goto("/settings/set-dev-ext-path")
}
const selected = await openFileSelector({
directory: false,
multiple: true, // allow install multiple extensions at once
filters: [
{
name: "tarball file",
extensions: ["tgz", "gz", "kunkun"]
}
]
})
if (!selected) {
return toast.warning("No File Selected")
}
console.log(selected)
for (const tarballPath of selected) {
await extensions.installTarball(tarballPath, $appConfig.devExtensionPath)
}
}
</script>
<div class="flex justify-center gap-3">
<Button size="sm" onclick={pickExtFolders}>Install from Extension Folders</Button>
<Button size="sm" onclick={pickExtFiles}>Install from Extension Tarball File</Button>
</div>
<StrikeSeparator class="my-1">
<h3 class="text-muted-foreground font-mono text-sm">Drag and Drop</h3>
</StrikeSeparator>
<Layouts.Center>
<DragNDrop
onDrop={(e) => {
handleDragNDropInstall(e.payload.paths)
}}
onEnter={() => (dragging = true)}
onCancelled={() => (dragging = false)}
>
<Card.Root
class={cn("h-36 w-96", dragging ? "border-lime-400/30" : "text-white hover:text-blue-200")}
>
<div class="flex h-full cursor-pointer items-center justify-center">
<div class={cn("flex flex-col items-center", dragging ? "text-lime-400/70" : "")}>
<IconMultiplexer
icon={{ value: "mdi:folder-cog-outline", type: IconEnum.Iconify }}
class="h-10 w-10"
/>
<small class="select-none font-mono text-xs">Drag and Drop</small>
<small class="select-none font-mono text-xs">Extension Folder or Tarball</small>
</div>
</div>
</Card.Root>
</DragNDrop>
</Layouts.Center>
<StrikeSeparator class="my-1">
<h3 class="text-muted-foreground font-mono text-sm">Install Tarball From URL</h3>
</StrikeSeparator>
<InstallTarballUrlForm />
<InstallNpmPackageNameForm />

View File

@ -0,0 +1,61 @@
<script lang="ts">
import { appConfig, extensions } from "@/stores"
import { Input } from "@kksh/svelte5"
import { Form } from "@kksh/ui"
import { goto } from "$app/navigation"
import { toast } from "svelte-sonner"
import SuperDebug, { defaults, superForm } from "sveltekit-superforms"
import { valibot, valibotClient } from "sveltekit-superforms/adapters"
import * as v from "valibot"
const npmPackageNameFormSchema = v.object({
name: v.pipe(v.string(), v.minLength(1))
})
async function onNpmPackageNameSubmit(data: v.InferOutput<typeof npmPackageNameFormSchema>) {
if (!$appConfig.devExtensionPath) {
toast.warning(
"Please set the dev extension path in the settings to install tarball extension"
)
return goto("/settings/set-dev-ext-path")
}
await extensions
.installFromNpmPackageName(data.name, $appConfig.devExtensionPath)
.then(() => {
toast.success("Success", { description: "Extension installed successfully" })
})
.catch((err) => {
toast.warning("Failed to install extension", { description: err })
})
}
const form = superForm(defaults(valibot(npmPackageNameFormSchema)), {
validators: valibotClient(npmPackageNameFormSchema),
SPA: true,
onUpdate({ form, cancel }) {
if (!form.valid) {
console.log("invalid")
return
}
console.log(form.data)
onNpmPackageNameSubmit(form.data)
cancel()
}
})
const { form: formData, enhance, errors } = form
</script>
<form method="POST" use:enhance>
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}
<flex items-center gap-2>
<Input {...props} bind:value={$formData.name} placeholder="NPM Package Name" />
<Form.Button class="my-1">Install</Form.Button>
</flex>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</form>

View File

@ -0,0 +1,62 @@
<script lang="ts">
import { appConfig, extensions } from "@/stores"
import { Input } from "@kksh/svelte5"
import { Form } from "@kksh/ui"
import { goto } from "$app/navigation"
import { toast } from "svelte-sonner"
import SuperDebug, { defaults, superForm } from "sveltekit-superforms"
import { valibot, valibotClient } from "sveltekit-superforms/adapters"
import * as v from "valibot"
const urlFormSchema = v.object({
url: v.pipe(v.string(), v.url())
})
async function onUrlSubmit(data: v.InferOutput<typeof urlFormSchema>) {
// data.url
// https://storage.huakun.tech/vscode-0.0.6.tgz
if (!$appConfig.devExtensionPath) {
toast.warning(
"Please set the dev extension path in the settings to install tarball extension"
)
return goto("/settings/set-dev-ext-path")
}
await extensions
.installFromTarballUrl(data.url, $appConfig.devExtensionPath)
.then(() => {
toast.success("Sucecss", { description: "Extension installed successfully" })
})
.catch((err) => {
toast.warning("Failed to install extension", { description: err })
})
}
const form = superForm(defaults(valibot(urlFormSchema)), {
validators: valibotClient(urlFormSchema),
SPA: true,
onUpdate({ form, cancel }) {
if (!form.valid) {
console.log("invalid")
return
}
console.log(form.data)
onUrlSubmit(form.data)
cancel()
}
})
const { form: formData, enhance, errors } = form
</script>
<form method="POST" use:enhance>
<Form.Field {form} name="url">
<Form.Control>
{#snippet children({ props })}
<flex items-center gap-2>
<Input {...props} bind:value={$formData.url} placeholder="Tarball URL" />
<Form.Button class="my-1">Install</Form.Button>
</flex>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</form>

View File

@ -1,12 +1,10 @@
import { getExtensionsFolder } from "@/constants"
import { themeConfigStore, updateTheme, type ThemeConfig } from "@kksh/svelte5"
import { createTauriSyncStore, type WithSyncStore } from "@/utils/sync-store"
import { updateTheme, type ThemeConfig } from "@kksh/svelte5"
import { PersistedAppConfig, type AppConfig } from "@kksh/types"
import * as path from "@tauri-apps/api/path"
import { remove } from "@tauri-apps/plugin-fs"
import { debug, error } from "@tauri-apps/plugin-log"
import * as os from "@tauri-apps/plugin-os"
import { load } from "@tauri-apps/plugin-store"
import { get, writable, type Writable } from "svelte/store"
import * as v from "valibot"
export const defaultAppConfig: AppConfig = {
@ -21,7 +19,7 @@ export const defaultAppConfig: AppConfig = {
launchAtLogin: true,
showInTray: true,
devExtensionPath: null,
extensionPath: undefined,
extensionsInstallDir: undefined,
hmr: false,
hideOnBlur: true,
extensionAutoUpgrade: true,
@ -35,25 +33,22 @@ interface AppConfigAPI {
setDevExtensionPath: (devExtensionPath: string | null) => void
}
function createAppConfig(): Writable<AppConfig> & AppConfigAPI {
const { subscribe, update, set } = writable<AppConfig>(defaultAppConfig)
function createAppConfig(): WithSyncStore<AppConfig> & AppConfigAPI {
const store = createTauriSyncStore("app-config", defaultAppConfig)
async function init() {
debug("Initializing app config")
const appDataDir = await path.appDataDir()
// const appConfigPath = await path.join(appDataDir, "appConfig.json")
// debug(`appConfigPath: ${appConfigPath}`)
const persistStore = await load("kk-config.json", { autoSave: true })
const loadedConfig = await persistStore.get("config")
const parseRes = v.safeParse(PersistedAppConfig, loadedConfig)
if (parseRes.success) {
console.log("Parse Persisted App Config Success", parseRes.output)
const extensionPath = await path.join(appDataDir, "extensions")
update((config) => ({
const extensionsInstallDir = await getExtensionsFolder()
store.update((config) => ({
...config,
...parseRes.output,
isInitialized: true,
extensionPath,
extensionsInstallDir,
platform: os.platform()
}))
} else {
@ -63,7 +58,7 @@ function createAppConfig(): Writable<AppConfig> & AppConfigAPI {
await persistStore.set("config", v.parse(PersistedAppConfig, defaultAppConfig))
}
subscribe(async (config) => {
store.subscribe(async (config) => {
console.log("Saving app config", config)
await persistStore.set("config", config)
updateTheme(config.theme)
@ -71,15 +66,13 @@ function createAppConfig(): Writable<AppConfig> & AppConfigAPI {
}
return {
setTheme: (theme: ThemeConfig) => update((config) => ({ ...config, theme })),
...store,
setTheme: (theme: ThemeConfig) => store.update((config) => ({ ...config, theme })),
setDevExtensionPath: (devExtensionPath: string | null) => {
console.log("setDevExtensionPath", devExtensionPath)
update((config) => ({ ...config, devExtensionPath }))
store.update((config) => ({ ...config, devExtensionPath }))
},
init,
subscribe,
update,
set
init
}
}

View File

@ -1,26 +1,42 @@
import type { AppState } from "@/types"
import { get, writable, type Writable } from "svelte/store"
import { findAllArgsInLink } from "@/cmds/quick-links"
import { Action as ActionSchema, CmdTypeEnum } from "@kksh/api/models"
import type { AppState } from "@kksh/types"
import type { CmdValue } from "@kksh/ui/types"
import { derived, get, writable, type Writable } from "svelte/store"
export const defaultAppState: AppState = {
searchTerm: "",
highlightedCmd: ""
highlightedCmd: "",
loadingBar: false,
defaultAction: "",
actionPanel: undefined
}
interface AppStateAPI {
clearSearchTerm: () => void
get: () => AppState
setLoadingBar: (loadingBar: boolean) => void
setDefaultAction: (defaultAction: string) => void
setActionPanel: (actionPanel?: ActionSchema.ActionPanel) => void
}
function createAppState(): Writable<AppState> & AppStateAPI {
const store = writable<AppState>(defaultAppState)
return {
subscribe: store.subscribe,
update: store.update,
set: store.set,
...store,
get: () => get(store),
clearSearchTerm: () => {
store.update((state) => ({ ...state, searchTerm: "" }))
},
setLoadingBar: (loadingBar: boolean) => {
store.update((state) => ({ ...state, loadingBar }))
},
setDefaultAction: (defaultAction: string) => {
store.update((state) => ({ ...state, defaultAction }))
},
setActionPanel: (actionPanel?: ActionSchema.ActionPanel) => {
store.update((state) => ({ ...state, actionPanel }))
}
}
}

View File

@ -0,0 +1,47 @@
import { supabase } from "@/supabase"
import type { AuthError, Session, User } from "@supabase/supabase-js"
import { get, writable, type Writable } from "svelte/store"
type State = { session: Session | null; user: User | null }
interface AuthAPI {
get: () => State
refresh: () => Promise<void>
signOut: () => Promise<{ error: AuthError | null }>
signInExchange: (code: string) => Promise<{ error: AuthError | null }>
}
function createAuth(): Writable<State> & AuthAPI {
const store = writable<State>({ session: null, user: null })
async function refresh() {
const {
data: { session },
error
} = await supabase.auth.getSession()
const {
data: { user }
} = await supabase.auth.getUser()
store.update((state) => ({ ...state, session, user }))
}
async function signOut() {
return supabase.auth.signOut().then((res) => {
refresh()
return res
})
}
async function signInExchange(code: string) {
return supabase.auth.exchangeCodeForSession(code).then((res) => {
refresh()
return res
})
}
return {
...store,
get: () => get(store),
refresh,
signOut,
signInExchange
}
}
export const auth = createAuth()

View File

@ -0,0 +1,23 @@
import { findAllArgsInLink } from "@/cmds/quick-links"
import { CmdTypeEnum } from "@kksh/api/models"
import type { CmdQuery, CmdValue } from "@kksh/ui/main"
import { derived, get, writable, type Writable } from "svelte/store"
import { appState } from "./appState"
function createCmdQueryStore(): Writable<CmdQuery[]> {
const store = writable<CmdQuery[]>([])
appState.subscribe(($appState) => {
if ($appState.highlightedCmd.startsWith("{")) {
const parsedCmd = JSON.parse($appState.highlightedCmd) as CmdValue
if (parsedCmd.cmdType === CmdTypeEnum.QuickLink && parsedCmd.data) {
return store.set(findAllArgsInLink(parsedCmd.data).map((arg) => ({ name: arg, value: "" })))
}
}
store.set([])
})
return {
...store
}
}
export const cmdQueries = createCmdQueryStore()

View File

@ -10,22 +10,25 @@ import { appConfig } from "./appConfig"
function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
init: () => Promise<void>
getExtensionsFromStore: () => ExtPackageJsonExtra[]
installTarball: (tarballPath: string, extsDir: string) => Promise<ExtPackageJsonExtra>
installDevExtensionDir: (dirPath: string) => Promise<ExtPackageJsonExtra>
installFromTarballUrl: (tarballUrl: string, installDir: string) => Promise<ExtPackageJsonExtra>
installFromNpmPackageName: (name: string, installDir: string) => Promise<ExtPackageJsonExtra>
findStoreExtensionByIdentifier: (identifier: string) => ExtPackageJsonExtra | undefined
registerNewExtensionByPath: (extPath: string) => Promise<ExtPackageJsonExtra>
uninstallStoreExtensionByIdentifier: (identifier: string) => Promise<ExtPackageJsonExtra>
upgradeStoreExtension: (identifier: string, tarballUrl: string) => Promise<ExtPackageJsonExtra>
} {
const { subscribe, update, set } = writable<ExtPackageJsonExtra[]>([])
const store = writable<ExtPackageJsonExtra[]>([])
function init() {
return extAPI.loadAllExtensionsFromDb().then((exts) => {
set(exts)
store.set(exts)
})
}
function getExtensionsFromStore() {
const extContainerPath = get(appConfig).extensionPath
const extContainerPath = get(appConfig).extensionsInstallDir
if (!extContainerPath) return []
return get(extensions).filter((ext) => !extAPI.isExtPathInDev(extContainerPath, ext.extPath))
}
@ -43,7 +46,7 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
return extAPI
.loadExtensionManifestFromDisk(await path.join(extPath, "package.json"))
.then((ext) => {
update((exts) => {
store.update((exts) => {
const existingExt = exts.find((e) => e.extPath === ext.extPath)
if (existingExt) return exts
return [...exts, ext]
@ -56,12 +59,36 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
})
}
/**
* Install extension from tarball file
* @param tarballPath absolute path to the tarball file
* @param extsDir absolute path to the extensions directory
* @returns loaded extension
*/
async function installTarball(tarballPath: string, extsDir: string) {
return extAPI.installTarballUrl(tarballPath, extsDir).then((extInstallPath) => {
return registerNewExtensionByPath(extInstallPath)
})
}
async function installDevExtensionDir(dirPath: string) {
return extAPI.installDevExtensionDir(dirPath).then((ext) => {
return registerNewExtensionByPath(ext.extPath)
})
}
async function installFromTarballUrl(tarballUrl: string, extsDir: string) {
return extAPI.installTarballUrl(tarballUrl, extsDir).then((extInstallPath) => {
return registerNewExtensionByPath(extInstallPath)
})
}
async function installFromNpmPackageName(name: string, extsDir: string) {
return extAPI.installFromNpmPackageName(name, extsDir).then((extInstallPath) => {
return registerNewExtensionByPath(extInstallPath)
})
}
async function uninstallExtensionByPath(targetPath: string) {
const targetExt = get(extensions).find((ext) => ext.extPath === targetPath)
if (!targetExt) throw new Error(`Extension ${targetPath} not registered in DB`)
@ -69,7 +96,7 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
return extAPI
.uninstallExtensionByPath(targetPath)
.then(() => update((exts) => exts.filter((ext) => ext.extPath !== targetExt.extPath)))
.then(() => store.update((exts) => exts.filter((ext) => ext.extPath !== targetExt.extPath)))
.then(() => targetExt)
}
@ -83,7 +110,7 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
identifier: string,
tarballUrl: string
): Promise<ExtPackageJsonExtra> {
const extsDir = get(appConfig).extensionPath
const extsDir = get(appConfig).extensionsInstallDir
if (!extsDir) throw new Error("Extension path not set")
return uninstallStoreExtensionByIdentifier(identifier).then(() =>
installFromTarballUrl(tarballUrl, extsDir)
@ -91,16 +118,17 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
}
return {
...store,
init,
getExtensionsFromStore,
findStoreExtensionByIdentifier,
registerNewExtensionByPath,
installTarball,
installDevExtensionDir,
installFromTarballUrl,
installFromNpmPackageName,
uninstallStoreExtensionByIdentifier,
upgradeStoreExtension,
subscribe,
update,
set
upgradeStoreExtension
}
}
@ -109,7 +137,7 @@ export const extensions = createExtensionsStore()
export const installedStoreExts: Readable<ExtPackageJsonExtra[]> = derived(
extensions,
($extensionsStore) => {
const extContainerPath = get(appConfig).extensionPath
const extContainerPath = get(appConfig).extensionsInstallDir
if (!extContainerPath) return []
return $extensionsStore.filter((ext) => !extAPI.isExtPathInDev(extContainerPath, ext.extPath))
}
@ -117,7 +145,7 @@ export const installedStoreExts: Readable<ExtPackageJsonExtra[]> = derived(
export const devStoreExts: Readable<ExtPackageJsonExtra[]> = derived(
extensions,
($extensionsStore) => {
const extContainerPath = get(appConfig).extensionPath
const extContainerPath = get(appConfig).extensionsInstallDir
if (!extContainerPath) return []
return $extensionsStore.filter((ext) => extAPI.isExtPathInDev(extContainerPath, ext.extPath))
}

View File

@ -2,3 +2,5 @@ export * from "./appConfig"
export * from "./appState"
export * from "./winExtMap"
export * from "./extensions"
export * from "./auth"
export * from "./quick-links"

View File

@ -0,0 +1,39 @@
import type { Icon } from "@kksh/api/models"
import { createQuickLinkCommand, getAllQuickLinkCommands } from "@kksh/extension/db"
import type { CmdQuery, QuickLink } from "@kksh/ui/types"
import { get, writable, type Writable } from "svelte/store"
export interface QuickLinkAPI {
get: () => QuickLink[]
init: () => Promise<void>
refresh: () => Promise<void>
createQuickLink: (name: string, link: string, icon: Icon) => Promise<void>
}
function createQuickLinksStore(): Writable<QuickLink[]> & QuickLinkAPI {
const store = writable<QuickLink[]>([])
async function init() {
refresh()
}
async function refresh() {
const cmds = await getAllQuickLinkCommands()
store.set(cmds.map((cmd) => ({ link: cmd.data.link, name: cmd.name, icon: cmd.data.icon })))
}
async function createQuickLink(name: string, link: string, icon: Icon) {
await createQuickLinkCommand(name, link, icon)
await refresh()
}
return {
...store,
get: () => get(store),
init,
refresh,
createQuickLink
}
}
export const quickLinks = createQuickLinksStore()

View File

@ -40,6 +40,7 @@ function createWinExtMapStore(): Writable<WinExtMap> & API {
async function init() {}
return {
...store,
init,
registerExtensionWithWindow: async ({
extPath,
@ -58,11 +59,11 @@ function createWinExtMapStore(): Writable<WinExtMap> & API {
await killProcesses(winExtMap[windowLabel].pids)
delete winExtMap[windowLabel]
} else {
winExtMap[windowLabel] = {
windowLabel,
extPath,
pids: []
}
// winExtMap[windowLabel] = {
// windowLabel,
// extPath,
// pids: []
// }
}
}
const returnedWinLabel = await registerExtensionWindow({
@ -70,6 +71,11 @@ function createWinExtMapStore(): Writable<WinExtMap> & API {
windowLabel,
dist
})
winExtMap[returnedWinLabel] = {
windowLabel: returnedWinLabel,
extPath,
pids: []
}
store.set(winExtMap)
return returnedWinLabel
},
@ -109,10 +115,7 @@ function createWinExtMapStore(): Writable<WinExtMap> & API {
return unregisterExtensionSpawnedProcess(windowLabel, pid).then(() => {
ext.pids = ext.pids.filter((p) => p !== pid)
})
},
subscribe: store.subscribe,
update: store.update,
set: store.set
}
}
}

View File

@ -0,0 +1,115 @@
import { emitRefreshDevExt } from "@/utils/tauri-events"
import {
DEEP_LINK_PATH_AUTH_CONFIRM,
DEEP_LINK_PATH_OPEN,
DEEP_LINK_PATH_REFRESH_DEV_EXTENSION,
DEEP_LINK_PATH_STORE
} from "@kksh/api"
import type { UnlistenFn } from "@tauri-apps/api/event"
import { extname } from "@tauri-apps/api/path"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import * as deepLink from "@tauri-apps/plugin-deep-link"
import { error } from "@tauri-apps/plugin-log"
import { goto } from "$app/navigation"
import { toast } from "svelte-sonner"
import * as v from "valibot"
import { isInMainWindow } from "./window"
const StorePathSearchParams = v.object({
identifier: v.optional(v.string())
})
export function initDeeplink(): Promise<UnlistenFn> {
console.log("init deeplink")
if (!isInMainWindow()) {
return Promise.resolve(() => {})
}
// deepLink.getCurrent()
return deepLink.onOpenUrl((urls) => {
console.log("deep link:", urls)
urls.forEach(handleDeepLink)
})
}
/**
* Show and focus on the main window
*/
function openMainWindow() {
const appWindow = getCurrentWebviewWindow()
return appWindow
.show()
.then(() => appWindow.setFocus())
.catch((err) => {
console.error(err)
error(`Failed to show window upon deep link: ${err.message}`)
toast.error("Failed to show window upon deep link", {
description: err.message
})
})
}
export async function handleKunkunProtocol(parsedUrl: URL) {
const params = Object.fromEntries(parsedUrl.searchParams)
const { host, pathname, href } = parsedUrl
if (href.startsWith(DEEP_LINK_PATH_OPEN)) {
openMainWindow()
} else if (href.startsWith(DEEP_LINK_PATH_STORE)) {
const parsed = v.parse(StorePathSearchParams, params)
openMainWindow()
if (parsed.identifier) {
goto(`/extension/store/${parsed.identifier}`)
} else {
goto("/extension/store")
}
} else if (href.startsWith(DEEP_LINK_PATH_REFRESH_DEV_EXTENSION)) {
emitRefreshDevExt()
} else if (href.startsWith(DEEP_LINK_PATH_AUTH_CONFIRM)) {
openMainWindow()
goto(`/auth/confirm?${parsedUrl.searchParams.toString()}`)
} else {
console.error("Invalid path:", pathname)
toast.error("Invalid path", {
description: parsedUrl.href
})
}
}
export async function handleFileProtocol(parsedUrl: URL) {
console.log("File protocol:", parsedUrl)
const filePath = parsedUrl.pathname // Remove the leading '//' kunkun://open?identifier=qrcode gives "open"
console.log("File path:", filePath)
// from file absolute path, get file extension
const fileExt = await extname(filePath)
console.log("File extension:", fileExt)
switch (fileExt) {
case "kunkun":
// TODO: Handle file protocol, install extension from file (essentially a .tgz file)
break
default:
console.error("Unknown file extension:", fileExt)
toast.error("Unknown file extension", {
description: fileExt
})
break
}
}
/**
*
* @param url Deep Link URl, e.g. kunkun://open
*/
export async function handleDeepLink(url: string) {
const parsedUrl = new URL(url)
switch (parsedUrl.protocol) {
case "kunkun:":
return handleKunkunProtocol(parsedUrl)
case "file:":
return handleFileProtocol(parsedUrl)
default:
console.error("Invalid Protocol:", parsedUrl.protocol)
toast.error("Invalid Protocol", {
description: parsedUrl.protocol
})
break
}
}

View File

@ -0,0 +1,3 @@
export function getActiveElementNodeName(): string | undefined {
return document.activeElement?.nodeName
}

View File

@ -23,3 +23,13 @@ export function goBackOnEscapeClearSearchTerm(e: KeyboardEvent) {
}
}
}
export function goHomeOnEscapeClearSearchTerm(e: KeyboardEvent) {
if (e.key === "Escape") {
if (appState.get().searchTerm) {
appState.clearSearchTerm()
} else {
goHome()
}
}
}

View File

@ -0,0 +1,44 @@
import * as evt from "@tauri-apps/api/event"
import { writable, type Writable } from "svelte/store"
export function buildEventName(storeName: string) {
return `app://sync-store-${storeName}`
}
export type WithSyncStore<T> = Writable<T> & {
listen: () => void
unlisten: evt.UnlistenFn | undefined
}
export function createTauriSyncStore<T>(storeName: string, initialValue: T): WithSyncStore<T> {
const store = writable<T>(initialValue)
let unlisten: evt.UnlistenFn | undefined
async function listen() {
console.log("[listen] start", storeName)
if (unlisten) {
console.log("[listen] already listening, skip")
return
}
const _unlisten = await evt.listen<{ value: T }>(buildEventName(storeName), (evt) => {
console.log(`[listen] update from tauri event`, storeName, evt.payload.value)
store.set(evt.payload.value)
})
const unsubscribe = store.subscribe((value) => {
console.log("[subscribe] got update, emit data", storeName, value)
evt.emit(buildEventName(storeName), { value })
})
unlisten = () => {
_unlisten()
unsubscribe()
unlisten = undefined
}
return unlisten
}
return {
...store,
listen,
unlisten
}
}

View File

@ -0,0 +1,57 @@
import { DEEP_LINK_PATH_REFRESH_DEV_EXTENSION } from "@kksh/api"
import {
emit,
emitTo,
listen,
TauriEvent,
type Event,
type EventCallback,
type UnlistenFn
} from "@tauri-apps/api/event"
export const FileDragDrop = "tauri://drag-drop"
export const FileDragEnter = "tauri://drag-enter"
export const FileDragLeave = "tauri://drag-leave"
export const FileDragOver = "tauri://drag-over"
export const NewClipboardItemAddedEvent = "new_clipboard_item_added"
export const RefreshConfigEvent = "kunkun://refresh-config"
export const RefreshExtEvent = "kunkun://refresh-extensions"
export function listenToFileDrop(cb: EventCallback<{ paths: string[] }>) {
return listen<{ paths: string[] }>(FileDragDrop, cb)
}
export function listenToWindowBlur(cb: EventCallback<null>) {
return listen(TauriEvent.WINDOW_BLUR, cb)
}
export function listenToWindowFocus(cb: EventCallback<null>) {
return listen(TauriEvent.WINDOW_FOCUS, cb)
}
export function listenToNewClipboardItem(cb: EventCallback<null>) {
return listen(NewClipboardItemAddedEvent, cb)
}
export function emitRefreshConfig() {
return emit(RefreshConfigEvent)
}
export function listenToRefreshConfig(cb: EventCallback<null>) {
return listen(RefreshConfigEvent, cb)
}
export function emitRefreshExt() {
return emitTo("main", RefreshExtEvent)
}
export function listenToRefreshExt(cb: EventCallback<null>) {
return listen(RefreshExtEvent, cb)
}
export function emitRefreshDevExt() {
return emit(DEEP_LINK_PATH_REFRESH_DEV_EXTENSION)
}
export function listenToRefreshDevExt(cb: EventCallback<null>) {
return listen(DEEP_LINK_PATH_REFRESH_DEV_EXTENSION, cb)
}

View File

@ -0,0 +1,88 @@
import { extensions } from "@/stores"
import { supabaseAPI } from "@/supabase"
import { isCompatible } from "@kksh/api"
import type { ExtPackageJsonExtra } from "@kksh/api/models"
import { greaterThan } from "@std/semver"
import { relaunch } from "@tauri-apps/plugin-process"
import { check } from "@tauri-apps/plugin-updater"
import { gt } from "semver"
import { toast } from "svelte-sonner"
import { get } from "svelte/store"
export async function checkUpdateAndInstall({ beta }: { beta?: boolean } = {}) {
const update = await check({
headers: {
"kk-updater-mode": beta ? "beta" : "stable"
}
})
if (update?.available) {
const confirmUpdate = await confirm(
`A new version ${update.version} is available. Do you want to install and relaunch?`
)
if (confirmUpdate) {
await update.downloadAndInstall()
await relaunch()
}
} else {
toast.info("You are on the latest version")
}
}
export async function checkSingleExtensionUpdate(
installedExt: ExtPackageJsonExtra,
autoupgrade: boolean
) {
const { data: sbExt, error } = await supabaseAPI.getLatestExtPublish(
installedExt.kunkun.identifier
)
if (error) {
return toast.error(`Failed to check update for ${installedExt.kunkun.identifier}: ${error}`)
}
if (!sbExt) {
return null
}
if (
gt(sbExt.version, installedExt.version) &&
(sbExt.api_version ? isCompatible(sbExt.api_version) : true)
) {
if (autoupgrade) {
await extensions
.upgradeStoreExtension(
sbExt.identifier,
supabaseAPI.translateExtensionFilePathToUrl(sbExt.tarball_path)
)
.then(() => {
toast.success(`${sbExt.name} upgraded`, {
description: `From ${installedExt.version} to ${sbExt.version}`
})
})
.catch((err) => {
toast.error(`Failed to upgrade ${sbExt.name}`, { description: err })
})
return true
} else {
console.log(`new version available ${installedExt.kunkun.identifier} ${sbExt.version}`)
toast.info(
`Extension ${installedExt.kunkun.identifier} has a new version ${sbExt.version}, you can upgrade in Store.`,
{ duration: 10_000 }
)
}
}
return false
}
export async function checkExtensionUpdate(autoupgrade: boolean = false) {
let upgradedCount = 0
for (const ext of get(extensions)) {
const upgraded = await checkSingleExtensionUpdate(ext, autoupgrade)
if (upgraded) {
upgradedCount++
}
}
if (upgradedCount > 0) {
toast.info(`${upgradedCount} extensions have been upgraded`)
}
}

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { goHome } from "@/utils/route"
import { Error, Layouts } from "@kksh/ui"
import { page } from "$app/stores"
@ -11,12 +12,12 @@
<svelte:window on:keydown={handleKeyDown} />
<Layouts.Center class="h-screen">
<Layouts.Center class="min-h-screen py-5">
<Error.RawErrorJSONPreset
title="Unknown Error"
class="w-fit max-w-screen-sm"
title="Error"
class="w-fit max-w-screen-sm border-2 border-red-500"
message={$page.error?.message ?? "Unknown Error"}
onnGoBack={() => window.history.back()}
onGoBack={goHome}
rawJsonError={JSON.stringify($page, null, 2)}
/>
</Layouts.Center>

View File

@ -1,7 +1,8 @@
<script lang="ts">
import AppContext from "@/components/context/AppContext.svelte"
import "../app.css"
import { appConfig, appState, extensions } from "@/stores"
import { appConfig, appState, extensions, quickLinks } from "@/stores"
import { initDeeplink } from "@/utils/deeplink"
import { isInMainWindow } from "@/utils/window"
import {
ModeWatcher,
@ -16,11 +17,19 @@
import { attachConsole } from "@tauri-apps/plugin-log"
import { onDestroy, onMount } from "svelte"
onMount(() => {
setTimeout(() => {
import("virtual:uno.css")
}, 1000)
})
let { children } = $props()
const unlisteners: UnlistenFn[] = []
onMount(async () => {
unlisteners.push(await attachConsole())
attachConsole().then((unlistener) => unlisteners.push(unlistener))
initDeeplink().then((unlistener) => unlisteners.push(unlistener))
quickLinks.init()
appConfig.init()
if (isInMainWindow()) {
extensions.init()

View File

@ -1,5 +1,12 @@
import { getExtensionsFolder } from "@/constants"
import type { LayoutLoad } from "./$types"
// Tauri doesn't have a Node.js server to do proper SSR
// so we will use adapter-static to prerender the app (SSG)
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const prerender = true
export const ssr = false
export const load: LayoutLoad = async () => {
return { extsInstallDir: await getExtensionsFolder() }
}

View File

@ -1,18 +1,130 @@
<!-- This file renders the main command palette, a list of commands -->
<script lang="ts">
import { commandLaunchers } from "@/cmds"
import { builtinCmds } from "@/cmds/builtin"
import CommandPalette from "@/components/main/CommandPalette.svelte"
import { appState } from "@/stores"
import { appConfig } from "@/stores/appConfig"
import { extensions } from "@/stores/extensions"
import "@kksh/ui"
import { systemCommands } from "@/cmds/system"
import { appConfig, appState, devStoreExts, installedStoreExts, quickLinks } from "@/stores"
import { cmdQueries } from "@/stores/cmdQuery"
import { getActiveElementNodeName } from "@/utils/dom"
import { openDevTools } from "@kksh/api/commands"
import type { ExtPackageJsonExtra } from "@kksh/api/models"
import { isExtPathInDev } from "@kksh/extension/utils"
import { Button, Command, DropdownMenu } from "@kksh/svelte5"
import type { AppConfig, AppState } from "@kksh/types"
import {
BuiltinCmds,
CustomCommandInput,
ExtCmdsGroup,
GlobalCommandPaletteFooter,
QuickLinks,
SystemCmds
} from "@kksh/ui/main"
import type { BuiltinCmd, CmdValue, CommandLaunchers } from "@kksh/ui/types"
import { cn, commandScore } from "@kksh/ui/utils"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { exit } from "@tauri-apps/plugin-process"
import { EllipsisVerticalIcon } from "lucide-svelte"
import type { Writable } from "svelte/store"
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (getActiveElementNodeName() === "INPUT") {
;(event.target as HTMLInputElement).value = ""
if ((event.target as HTMLInputElement | undefined)?.id === "main-command-input") {
$appState.searchTerm = ""
}
}
}
}
</script>
<CommandPalette
class="h-screen"
extensions={$extensions}
{appState}
{appConfig}
{commandLaunchers}
{builtinCmds}
/>
<svelte:window on:keydown={onKeyDown} />
<Command.Root
class={cn("h-screen rounded-lg border shadow-md")}
bind:value={$appState.highlightedCmd}
filter={(value, search, keywords) => {
return commandScore(
value.startsWith("{") ? (JSON.parse(value) as CmdValue).cmdName : value,
search,
keywords
)
}}
loop
>
<CustomCommandInput
autofocus
id="main-command-input"
placeholder={$cmdQueries.length === 0 ? "Type a command or search..." : undefined}
bind:value={$appState.searchTerm}
>
{#snippet rightSlot()}
<span
class={cn("absolute flex space-x-2")}
style={`left: ${$appState.searchTerm.length + 3}ch`}
>
{#each $cmdQueries as cmdQuery}
{@const queryWidth = Math.max(cmdQuery.name.length, cmdQuery.value.length) + 2}
<input
class="bg-muted rounded-md border border-gray-300 pl-2 font-mono focus:outline-none dark:border-gray-600"
type="text"
placeholder={cmdQuery.name}
style={`width: ${queryWidth}ch`}
onkeydown={(evt) => {
if (evt.key === "Enter") {
evt.preventDefault()
evt.stopPropagation()
commandLaunchers.onQuickLinkSelect(
JSON.parse($appState.highlightedCmd),
$cmdQueries
)
}
}}
bind:value={cmdQuery.value}
/>
{/each}
</span>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="outline" size="icon"><EllipsisVerticalIcon /></Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Settings</DropdownMenu.GroupHeading>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => exit()}>Quit</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openDevTools()}>Open Dev Tools</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => getCurrentWebviewWindow().hide()}
>Close Window</DropdownMenu.Item
>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</CustomCommandInput>
<Command.List class="max-h-screen grow">
<Command.Empty data-tauri-drag-region>No results found.</Command.Empty>
{#if $appConfig.extensionsInstallDir && $devStoreExts.length > 0}
<ExtCmdsGroup
extensions={$devStoreExts}
heading="Dev Extensions"
isDev={true}
onExtCmdSelect={commandLaunchers.onExtCmdSelect}
hmr={$appConfig.hmr}
/>
{/if}
{#if $appConfig.extensionsInstallDir && $installedStoreExts.length > 0}
<ExtCmdsGroup
extensions={$installedStoreExts}
heading="Extensions"
isDev={false}
hmr={false}
onExtCmdSelect={commandLaunchers.onExtCmdSelect}
/>
{/if}
<QuickLinks quickLinks={$quickLinks} />
<BuiltinCmds {builtinCmds} />
<SystemCmds {systemCommands} />
</Command.List>
<GlobalCommandPaletteFooter />
</Command.Root>

View File

@ -0,0 +1,62 @@
<script lang="ts">
import { auth } from "@/stores"
import { supabase } from "@/supabase"
import { goBackOnEscape } from "@/utils/key"
import { goBack, goHome } from "@/utils/route"
import Icon from "@iconify/svelte"
import { DEEP_LINK_PATH_AUTH_CONFIRM } from "@kksh/api"
import { Button, Card } from "@kksh/svelte5"
import { Layouts } from "@kksh/ui"
import { goto } from "$app/navigation"
import { ArrowLeft } from "lucide-svelte"
import { onMount } from "svelte"
import { toast } from "svelte-sonner"
import { open } from "tauri-plugin-shellx-api"
const redirectTo = DEEP_LINK_PATH_AUTH_CONFIRM
const signInWithOAuth = async (provider: "github" | "google") => {
console.log(`Login with ${provider} redirecting to ${redirectTo}`)
const { error, data } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo,
skipBrowserRedirect: true
}
})
if (error) {
toast.error("Failed to sign in with OAuth", { description: error.message })
} else {
data.url && open(data.url)
}
}
onMount(() => {
if ($auth.session) {
toast.success("Already Signed In")
goHome()
}
})
</script>
<svelte:window on:keydown={goBackOnEscape} />
<Button variant="outline" size="icon" onclick={goBack} class="absolute left-2 top-2 z-50">
<ArrowLeft class="size-4" />
</Button>
<div class="absolute h-10 w-full" data-tauri-drag-region></div>
<Layouts.Center class="h-screen w-screen" data-tauri-drag-region>
<Card.Root class="w-80">
<Card.Header class="flex flex-col items-center">
<img src="/favicon.png" alt="Kunkun" class="h-12 w-12 invert" />
<Card.Title class="text-xl">Sign In</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-2">
<Button variant="outline" size="lg" class="w-full" onclick={() => signInWithOAuth("github")}>
<Icon icon="fa6-brands:github" class="h-5 w-5" />
</Button>
<Button variant="outline" size="lg" class="w-full" onclick={() => signInWithOAuth("google")}>
<Icon icon="logos:google-icon" class="h-5 w-5" />
</Button>
</Card.Content>
</Card.Root>
</Layouts.Center>

View File

@ -0,0 +1,84 @@
<script lang="ts">
import { auth } from "@/stores"
import { supabase } from "@/supabase"
import { goHomeOnEscape } from "@/utils/key"
import { goBack, goHome } from "@/utils/route"
import { Avatar, Button } from "@kksh/svelte5"
import { goto } from "$app/navigation"
import { ArrowLeft } from "lucide-svelte"
import { onMount } from "svelte"
import { toast } from "svelte-sonner"
const { data } = $props()
async function authExchange() {
if (data.code) {
auth.signInExchange(data.code).then((res) => {
if (res.error) {
toast.error("Failed to sign in", { description: res.error.message })
} else {
toast.success("Signed In")
}
})
} else {
toast.error("No code found")
}
}
const avatarFallback = $derived.by(() => {
if (!$auth.session) return "?"
const nameSplit = $auth.session.user.user_metadata.name.split(" ").filter(Boolean)
if (nameSplit.length > 1) {
return nameSplit[0][0] + nameSplit.at(-1)[0]
} else if (nameSplit.length === 1) {
return nameSplit[0][0]
} else {
return "?"
}
})
onMount(() => {
authExchange()
})
function onSignOut() {
auth
.signOut()
.then(() => goto("/auth"))
.catch((err) => toast.error("Failed to sign out", { description: err.message }))
}
</script>
<svelte:window on:keydown={goHomeOnEscape} />
<Button
class="absolute left-2 top-2 z-50"
variant="outline"
size="icon"
onclick={() => {
console.log("go Home")
goto("/")
}}
>
<ArrowLeft class="size-4" />
</Button>
<div class="h-10 w-full" data-tauri-drag-region></div>
<main class="container pt-10">
<div class="flex grow items-center justify-center pt-16">
<div class="flex flex-col items-center gap-4">
{#if $auth.session}
<span class="font-mono text-4xl font-bold">Welcome, You are Logged In</span>
{:else}
<span class="font-mono text-4xl font-bold">You Are Not Logged In</span>
{/if}
<span class="flex flex-col items-center gap-5 text-xl">
{#if $auth.session}
<Avatar.Root class="h-32 w-32 border">
<Avatar.Image src={$auth.session?.user.user_metadata.avatar_url} alt="avatar" />
<Avatar.Fallback>{avatarFallback}</Avatar.Fallback>
</Avatar.Root>
{/if}
<Button variant="outline" onclick={onSignOut}>Sign Out</Button>
</span>
</div>
</div>
</main>

View File

@ -0,0 +1,10 @@
import { error } from "@sveltejs/kit"
import type { PageLoad } from "./$types"
export const load: PageLoad = async ({ params, url }) => {
const code = url.searchParams.get("code")
if (!code) {
throw error(400, "Auth Exchange Code is Required")
}
return { params, code }
}

View File

@ -0,0 +1,108 @@
<script lang="ts">
import { quickLinks } from "@/stores/quick-links"
import { goBackOnEscape } from "@/utils/key"
import { goBack } from "@/utils/route"
import { Icon, IconEnum, IconType } from "@kksh/api/models"
import { Button, Input } from "@kksh/svelte5"
import { Form, IconSelector } from "@kksh/ui"
import { dev } from "$app/environment"
import { ArrowLeftIcon } from "lucide-svelte"
import { toast } from "svelte-sonner"
import SuperDebug, { defaults, superForm } from "sveltekit-superforms"
import { valibot, valibotClient } from "sveltekit-superforms/adapters"
import * as v from "valibot"
const formSchema = v.object({
name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
link: v.pipe(v.string(), v.url(), v.minLength(5), v.maxLength(1000)),
iconType: IconType,
iconValue: v.string(),
invertIcon: v.boolean()
})
let icon = $state<Icon>({
type: IconEnum.Iconify,
value: "material-symbols:link",
invert: false
})
const form = superForm(defaults(valibot(formSchema)), {
validators: valibotClient(formSchema),
SPA: true,
onUpdate({ form, cancel }) {
cancel()
if (!form.valid) return
const { name, link, iconType, iconValue } = form.data
quickLinks
.createQuickLink(name, link, icon)
.then(() => {
toast.success("Quicklink created successfully")
goBack()
})
.catch((err) => {
toast.error("Failed to create quicklink", { description: err })
})
}
})
const { form: formData, enhance, errors } = form
const placeholders = {
name: "Quick Link Name",
link: "https://google.com/search?q={argument}"
}
const defaultFaviconUrl = $derived(
$formData.link ? new URL($formData.link).origin + "/favicon.ico" : undefined
)
$effect(() => {
if (defaultFaviconUrl && defaultFaviconUrl.length > 0) {
icon.type = IconEnum.RemoteUrl
icon.value = defaultFaviconUrl
}
})
$effect(() => {
$formData.iconType = icon.type
$formData.iconValue = icon.value
$formData.invertIcon = icon.invert ?? false
})
</script>
<svelte:window on:keydown={goBackOnEscape} />
<Button variant="outline" size="icon" class="fixed left-2 top-2 z-50" onclick={goBack}>
<ArrowLeftIcon class="h-4 w-4" />
</Button>
<div class="h-12" data-tauri-drag-region></div>
<div class="container">
<h1 class="text-2xl font-bold">Create Quick Link</h1>
<form method="POST" use:enhance>
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} bind:value={$formData.name} placeholder={placeholders.name} />
{/snippet}
</Form.Control>
<Form.Description>Quick Link Display Name</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="link">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Link</Form.Label>
<Input {...props} bind:value={$formData.link} placeholder={placeholders.link} />
{/snippet}
</Form.Control>
<Form.Description>Quick Link URL</Form.Description>
<Form.FieldErrors />
</Form.Field>
<IconSelector class="border" bind:icon />
<input name="iconType" hidden type="text" bind:value={$formData.iconType} />
<input name="iconValue" hidden type="text" bind:value={$formData.iconValue} />
<input name="invertIcon" hidden type="text" bind:value={$formData.invertIcon} />
<Form.Button class="my-1">Submit</Form.Button>
</form>
</div>
{#if dev}
<div class="p-5">
<SuperDebug data={$formData} />
</div>
{/if}

View File

@ -0,0 +1,7 @@
import { z } from "zod"
export const formSchema = z.object({
username: z.string().min(2).max(50)
})
export type FormSchema = typeof formSchema

View File

@ -0,0 +1,100 @@
<script lang="ts">
import { goBackOnEscape } from "@/utils/key"
import { goBack } from "@/utils/route"
import { listenToFileDrop } from "@/utils/tauri-events"
import type { ExtPackageJsonExtra } from "@kksh/api/models"
import { loadExtensionManifestFromDisk } from "@kksh/extension"
import { Button, Card } from "@kksh/svelte5"
import { PermissionInspector } from "@kksh/ui/extension"
import type { UnlistenFn } from "@tauri-apps/api/event"
import { join } from "@tauri-apps/api/path"
import { getCurrentWebview } from "@tauri-apps/api/webview"
import { open as openDialog } from "@tauri-apps/plugin-dialog"
import { exists } from "@tauri-apps/plugin-fs"
import { ArrowLeftIcon } from "lucide-svelte"
import { onDestroy, onMount } from "svelte"
import { toast } from "svelte-sonner"
let pkgJsons = $state<ExtPackageJsonExtra[]>([])
let unlistenDropEvt: UnlistenFn
onMount(async () => {
unlistenDropEvt = await getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === "drop") {
inspectPaths(event.payload.paths)
}
})
})
onDestroy(() => {
unlistenDropEvt?.()
})
async function inspectPaths(paths: string[]) {
for (const path of paths) {
if (!(await exists(path))) {
toast.error("Selected path does not exist", { description: path })
continue
}
const manifestPath = await join(path, "package.json")
if (!(await exists(manifestPath))) {
toast.error("Selected path is not an extension", { description: path })
continue
}
try {
pkgJsons.push(await loadExtensionManifestFromDisk(manifestPath))
toast.success("Extension manifest loaded", { description: path })
} catch (err) {
toast.error(`Failed to load extension manifest: ${err}`, { description: path })
}
}
}
async function onPick() {
const paths = await openDialog({
directory: true,
multiple: true
})
if (!paths) {
return toast.error("No folder selected")
}
inspectPaths(paths)
}
</script>
<svelte:window on:keydown={goBackOnEscape} />
<main class="container w-screen pt-10">
<Button variant="outline" size="icon" class="absolute left-2 top-2 z-50" onclick={goBack}>
<ArrowLeftIcon class="h-4 w-4" />
</Button>
<h1 class="text-2xl font-bold">Extension Permission Inspector</h1>
<Button class="my-5" onclick={onPick}>Pick Extension Folder to Inspect</Button>
<div class="mb-5 flex flex-col gap-4">
{#each pkgJsons as pkgJson}
<Card.Root>
<Card.Header>
<Card.Title>{pkgJson.kunkun.name}</Card.Title>
<Card.Description>{pkgJson.kunkun.shortDescription}</Card.Description>
</Card.Header>
<Card.Content>
<PermissionInspector manifest={pkgJson.kunkun} />
</Card.Content>
<Card.Footer class="block">
<p class="text-sm">
<strong>Identifier:</strong> <code>{pkgJson.kunkun.identifier}</code>
</p>
<p class="text-sm">
<strong>Extension Path:</strong> <code>{pkgJson.extPath}</code>
</p>
</Card.Footer>
</Card.Root>
{/each}
</div>
</main>
<style>
:global(body) {
overflow-x: hidden;
}
</style>

View File

@ -2,8 +2,8 @@
import { getExtensionsFolder } from "@/constants"
import { appState, extensions } from "@/stores"
import { supabaseAPI } from "@/supabase"
import { goBackOnEscapeClearSearchTerm } from "@/utils/key"
import { goBack } from "@/utils/route"
import { goBackOnEscapeClearSearchTerm, goHomeOnEscapeClearSearchTerm } from "@/utils/key"
import { goBack, goHome } from "@/utils/route"
import { SBExt } from "@kksh/api/supabase"
import { isUpgradable } from "@kksh/extension"
import { Button, Command } from "@kksh/svelte5"
@ -64,20 +64,19 @@
}
</script>
<svelte:window on:keydown={goBackOnEscapeClearSearchTerm} />
<svelte:window on:keydown={goHomeOnEscapeClearSearchTerm} />
{#snippet leftSlot()}
<Button
variant="outline"
size="icon"
onclick={goBack}
onclick={goHome}
class={Constants.CLASSNAMES.BACK_BUTTON}
data-flip-id={Constants.CLASSNAMES.BACK_BUTTON}
>
<ArrowLeft class="size-4" />
</Button>
{/snippet}
<Command.Root class="h-screen rounded-lg border shadow-md">
<Command.Root class="h-screen rounded-lg border shadow-md" loop>
<CustomCommandInput
autofocus
placeholder="Type a command or search..."

View File

@ -12,12 +12,12 @@
<svelte:window on:keydown={handleKeyDown} />
<Layouts.Center class="h-screen">
<Layouts.Center class="min-h-screen py-5">
<Error.RawErrorJSONPreset
title="Fail to Load Extension"
class="w-fit max-w-screen-sm"
class="w-fit max-w-screen-sm border-2 border-red-500"
message={$page.error?.message ?? "Unknown Error"}
onnGoBack={() => goto("/")}
onGoBack={() => goto("/")}
rawJsonError={JSON.stringify($page, null, 2)}
/>
</Layouts.Center>

View File

@ -9,13 +9,15 @@
import { StoreExtDetail } from "@kksh/ui/extension"
import { greaterThan, parse as parseSemver } from "@std/semver"
import { error } from "@tauri-apps/plugin-log"
import { goto } from "$app/navigation"
import { ArrowLeftIcon } from "lucide-svelte"
import { onMount } from "svelte"
import { toast } from "svelte-sonner"
import { get, derived as storeDerived } from "svelte/store"
const { data } = $props()
let { ext, manifest } = data
const ext = $derived(data.ext)
const manifest = $derived(data.manifest)
const installedExt = storeDerived(installedStoreExts, ($e) => {
return $e.find((e) => e.kunkun.identifier === ext.identifier)
})
@ -36,9 +38,9 @@
onMount(() => {
showBtn = {
install: !installedExt,
install: !$installedExt,
upgrade: isUpgradable,
uninstall: !!installedExt
uninstall: !!$installedExt
}
})
@ -114,43 +116,44 @@
.uninstallStoreExtensionByIdentifier(ext.identifier)
.then((uninstalledExt) => {
toast.success(`${uninstalledExt.name} Uninstalled`)
loading.uninstall = false
showBtn.uninstall = false
showBtn.install = true
})
.catch((err) => {
toast.error("Fail to uninstall extension", { description: err })
error(`Fail to uninstall store extension (${ext.identifier}): ${err}`)
})
.finally(() => {
loading.uninstall = false
showBtn.uninstall = false
showBtn.install = true
})
.finally(() => {})
}
function onEnterPressed() {
return onInstallSelected()
if (showBtn.install) {
return onInstallSelected()
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (!delayedImageDialogOpen) {
goBack()
goto("/extension/store")
}
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<Button
variant="outline"
size="icon"
class={cn("fixed left-3 top-3", Constants.CLASSNAMES.BACK_BUTTON)}
data-flip-id={Constants.CLASSNAMES.BACK_BUTTON}
onclick={goBack}
onclick={() => goto("/extension/store")}
>
<ArrowLeftIcon />
</Button>
<StoreExtDetail
class="px-5"
{ext}
{manifest}
installedExt={$installedExt}

View File

@ -13,7 +13,12 @@ export const load: PageLoad = async ({
}): Promise<{
ext: Tables<"ext_publish">
manifest: KunkunExtManifest
params: {
identifier: string
}
}> => {
console.log("store[identifier] params", params)
const { error: dbError, data: ext } = await supabaseAPI.getLatestExtPublish(params.identifier)
if (dbError) {
return error(400, {
@ -30,6 +35,7 @@ export const load: PageLoad = async ({
return {
ext,
params,
manifest: parseManifest.output
}
}

View File

@ -1 +1,289 @@
<script lang="ts"></script>
<script lang="ts">
import { appState } from "@/stores/appState.js"
import { winExtMap } from "@/stores/winExtMap.js"
import { listenToRefreshDevExt } from "@/utils/tauri-events.js"
import { isInMainWindow } from "@/utils/window.js"
import { type Remote } from "@huakunshen/comlink"
import { db } from "@kksh/api/commands"
import {
constructJarvisServerAPIWithPermissions,
exposeApiToWorker,
type IApp,
type IUiWorker
} from "@kksh/api/ui"
import {
// constructJarvisExtDBToServerDbAPI,
FormNodeNameEnum,
FormSchema,
ListSchema,
Markdown,
MarkdownSchema,
NodeNameEnum,
toast,
wrap,
type IComponent,
type IDb,
type WorkerExtension
} from "@kksh/api/ui/worker"
import { Button } from "@kksh/svelte5"
import { LoadingBar } from "@kksh/ui"
import { Templates } from "@kksh/ui/extension"
import { GlobalCommandPaletteFooter } from "@kksh/ui/main"
import type { UnlistenFn } from "@tauri-apps/api/event"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { readTextFile } from "@tauri-apps/plugin-fs"
import { debug } from "@tauri-apps/plugin-log"
import { goto } from "$app/navigation"
import { ArrowLeftIcon } from "lucide-svelte"
import { onDestroy, onMount } from "svelte"
import * as v from "valibot"
const { data } = $props()
let { loadedExt, scriptPath, extInfoInDB } = $derived(data)
let workerAPI: Remote<WorkerExtension> | undefined = undefined
let unlistenRefreshWorkerExt: UnlistenFn | undefined
let worker: Worker | undefined
let listViewContent = $state<ListSchema.List>()
let formViewContent = $state<FormSchema.Form>()
let markdownViewContent = $state<MarkdownSchema>()
let extensionLoadingBar = $state(false) // whether extension called showLoadingBar
let pbar = $state<number | null>(null)
let loading = $state(false)
let searchTerm = $state("")
let searchBarPlaceholder = $state("")
const appWin = getCurrentWebviewWindow()
const loadingBar = $derived($appState.loadingBar || extensionLoadingBar)
let loaded = $state(false)
async function goBack() {
if (isInMainWindow()) {
// if in main window, then winExtMap store must contain this
winExtMap.unregisterExtensionFromWindow(appWin.label)
goto("/")
} else {
appWin.close()
}
}
function clearViewContent(keep?: "list" | "form" | "markdown") {
if (keep !== "list") {
listViewContent = undefined
}
if (keep !== "form") {
formViewContent = undefined
}
if (keep !== "markdown") {
markdownViewContent = undefined
}
}
const extUiAPI: IUiWorker = {
async render(view: IComponent<ListSchema.List | FormSchema.Form | MarkdownSchema>) {
if (view.nodeName === NodeNameEnum.List) {
clearViewContent("list")
const parsedListView = v.parse(ListSchema.List, view)
const updateFields = {
sections: true,
items: true,
detail: true,
filter: true,
actions: true,
defaultAction: true
}
if (listViewContent) {
if (parsedListView.inherits && parsedListView.inherits.length > 0) {
if (parsedListView.inherits.includes("items")) {
updateFields.items = false
}
if (parsedListView.inherits.includes("sections")) {
updateFields.sections = false
}
if (parsedListView.inherits.includes("detail")) {
updateFields.detail = false
}
if (parsedListView.inherits.includes("filter")) {
updateFields.filter = false
}
if (parsedListView.inherits.includes("actions")) {
updateFields.actions = false
}
if (parsedListView.inherits.includes("defaultAction")) {
updateFields.defaultAction = false
}
if (updateFields.items) {
listViewContent.items = parsedListView.items
}
if (updateFields.sections) {
listViewContent.sections = parsedListView.sections
}
if (updateFields.detail) {
listViewContent.detail = parsedListView.detail
}
if (updateFields.filter) {
listViewContent.filter = parsedListView.filter
}
if (updateFields.actions) {
listViewContent.actions = parsedListView.actions
}
if (updateFields.defaultAction) {
listViewContent.defaultAction = parsedListView.defaultAction
}
listViewContent.inherits = parsedListView.inherits
} else {
listViewContent = parsedListView
}
} else {
listViewContent = parsedListView
}
// if (parsedListView.updateDetailOnly) {
// if (listViewContent) {
// listViewContent.detail = parsedListView.detail
// } else {
// listViewContent = parsedListView
// }
// } else {
// listViewContent = parsedListView
// }
} else if (view.nodeName === FormNodeNameEnum.Form) {
listViewContent = undefined
clearViewContent("form")
const parsedForm = v.parse(FormSchema.Form, view)
formViewContent = parsedForm
// TODO: convert form to zod schema
// const zodSchema = convertFormToZod(parsedForm)
// formViewZodSchema = zodSchema
// formFieldConfig = buildFieldConfig(parsedForm)
} else if (view.nodeName === NodeNameEnum.Markdown) {
clearViewContent("markdown")
markdownViewContent = v.parse(MarkdownSchema, view)
} else {
toast.error(`Unsupported view type: ${view.nodeName}`)
}
},
async showLoadingBar(loading: boolean) {
// appState.setLoadingBar(loading)
extensionLoadingBar = loading
},
async setProgressBar(progress: number | null) {
pbar = progress
},
async setScrollLoading(_loading: boolean) {
loading = _loading
},
async setSearchTerm(term: string) {
searchTerm = term
},
async setSearchBarPlaceholder(placeholder: string) {
console.log("setSearchBarPlaceholder", placeholder)
searchBarPlaceholder = placeholder
},
async goBack() {
goBack()
}
}
async function launchWorkerExt() {
if (worker) {
worker.terminate()
worker = undefined
}
const workerScript = await readTextFile(scriptPath)
const blob = new Blob([workerScript], { type: "application/javascript" })
const blobURL = URL.createObjectURL(blob)
worker = new Worker(blobURL)
const serverAPI: Record<string, any> = constructJarvisServerAPIWithPermissions(
loadedExt.kunkun.permissions,
loadedExt.extPath
)
serverAPI.iframeUi = undefined
serverAPI.workerUi = extUiAPI
serverAPI.db = new db.JarvisExtDB(extInfoInDB.extId)
serverAPI.app = {
language: () => Promise.resolve("en")
} satisfies IApp
exposeApiToWorker(worker, serverAPI)
workerAPI = wrap<WorkerExtension>(worker)
await workerAPI.load()
}
$effect(() => {
launchWorkerExt()
return () => {
worker?.terminate()
}
})
onMount(async () => {
setTimeout(() => {
appState.setLoadingBar(true)
appWin.show()
}, 100)
unlistenRefreshWorkerExt = await listenToRefreshDevExt(() => {
debug("Refreshing Worker Extension")
launchWorkerExt()
})
setTimeout(() => {
appState.setLoadingBar(false)
loaded = true
}, 500)
})
onDestroy(() => {
unlistenRefreshWorkerExt?.()
extensionLoadingBar = false
appState.setActionPanel(undefined)
})
</script>
{#if loadingBar}
<LoadingBar class="fixed left-0 top-0 w-full" color="white" />
{/if}
{#if loaded && listViewContent !== undefined}
<Templates.ListView
bind:searchTerm
bind:searchBarPlaceholder
{pbar}
{listViewContent}
{loading}
onGoBack={goBack}
onListScrolledToBottom={() => {
workerAPI?.onListScrolledToBottom()
}}
onEnterKeyPressed={() => {
workerAPI?.onEnterPressedOnSearchBar()
}}
onListItemSelected={(value: string) => {
workerAPI?.onListItemSelected(value)
}}
onSearchTermChange={(searchTerm) => {
workerAPI?.onSearchTermChange(searchTerm)
}}
onHighlightedItemChanged={(value) => {
workerAPI?.onHighlightedListItemChanged(value)
if (listViewContent?.defaultAction) {
appState.setDefaultAction(listViewContent.defaultAction)
}
if (listViewContent?.actions) {
appState.setActionPanel(listViewContent.actions)
}
}}
>
{#snippet footer()}
<GlobalCommandPaletteFooter
defaultAction={$appState.defaultAction}
actionPanel={$appState.actionPanel}
onDefaultActionSelected={() => {
workerAPI?.onEnterPressedOnSearchBar()
}}
onActionSelected={(value) => {
workerAPI?.onActionSelected(value)
}}
/>
{/snippet}
</Templates.ListView>
{:else if loaded && formViewContent !== undefined}
<Templates.FormView {formViewContent} onGoBack={goBack} />
{:else if loaded && markdownViewContent !== undefined}
<Templates.MarkdownView {markdownViewContent} onGoBack={goBack} />
{/if}

View File

@ -0,0 +1,76 @@
import { db, unregisterExtensionWindow } from "@kksh/api/commands"
import type { Ext as ExtInfoInDB, ExtPackageJsonExtra } from "@kksh/api/models"
import { loadExtensionManifestFromDisk } from "@kksh/extension"
import { error as sbError } from "@sveltejs/kit"
import { join } from "@tauri-apps/api/path"
import { exists, readTextFile } from "@tauri-apps/plugin-fs"
import { error } from "@tauri-apps/plugin-log"
import { goto } from "$app/navigation"
import { toast } from "svelte-sonner"
import type { PageLoad } from "./$types"
// : Promise<{
// extPath: string
// scriptPath: string
// // workerScript: string
// pkgJsonPath: string
// cmdName: string
// loadedExt: ExtPackageJsonExtra
// extInfoInDB: ExtInfoInDB
// }>
export const load: PageLoad = async ({ url }) => {
// both query parameter must exist
const extPath = url.searchParams.get("extPath")
const cmdName = url.searchParams.get("cmdName")
if (!extPath || !cmdName) {
toast.error("Invalid extension path or url")
error("Invalid extension path or url")
goto("/")
}
let _loadedExt: ExtPackageJsonExtra | undefined
try {
_loadedExt = await loadExtensionManifestFromDisk(await join(extPath!, "package.json"))
} catch (err) {
error(`Error loading extension manifest: ${err}`)
toast.error("Error loading extension manifest", {
description: `${err}`
})
goto("/")
}
const loadedExt = _loadedExt!
const extInfoInDB = await db.getUniqueExtensionByPath(loadedExt.extPath)
if (!extInfoInDB) {
toast.error("Unexpected Error", {
description: `Extension ${loadedExt.kunkun.identifier} not found in database. Run Troubleshooter.`
})
goto("/")
}
const pkgJsonPath = await join(extPath!, "package.json")
if (!(await exists(extPath!))) {
sbError(404, `Extension not found at ${extPath}`)
}
if (!(await exists(pkgJsonPath))) {
sbError(404, `Extension package.json not found at ${pkgJsonPath}`)
}
const cmd = loadedExt.kunkun.templateUiCmds.find((cmd) => cmd.name === cmdName)
if (!cmd) {
sbError(404, `Command ${cmdName} not found in extension ${loadedExt.kunkun.identifier}`)
}
const scriptPath = await join(loadedExt.extPath, cmd.main)
if (!(await exists(scriptPath))) {
sbError(404, `Command script not found at ${scriptPath}`)
}
// const workerScript = await readTextFile(scriptPath)
return {
extPath: extPath!,
pkgJsonPath,
scriptPath,
// workerScript,
cmdName: cmdName!,
loadedExt,
extInfoInDB: extInfoInDB!
}
}

View File

@ -0,0 +1,31 @@
<script lang="ts">
import AddDevExtForm from "@/components/standalone/settings/AddDevExtForm.svelte"
import DevExtPathForm from "@/components/standalone/settings/DevExtPathForm.svelte"
import { appConfig, extensions } from "@/stores"
import { goBackOnEscape } from "@/utils/key"
import { goBack } from "@/utils/route"
import * as extAPI from "@kksh/extension"
import { installFromNpmPackageName } from "@kksh/extension"
import { Button, Separator } from "@kksh/svelte5"
import { StrikeSeparator } from "@kksh/ui"
import { open as openFileSelector } from "@tauri-apps/plugin-dialog"
import * as fs from "@tauri-apps/plugin-fs"
import { goto } from "$app/navigation"
import { ArrowLeftIcon } from "lucide-svelte"
import { toast } from "svelte-sonner"
import * as v from "valibot"
</script>
<svelte:window on:keydown={goBackOnEscape} />
<Button variant="outline" size="icon" class="fixed left-2 top-2 z-50" onclick={goBack}>
<ArrowLeftIcon class="h-4 w-4" />
</Button>
<div class="absolute left-0 top-0 h-10 w-screen" data-tauri-drag-region></div>
<main class="container pt-10">
<h2 class="text-2xl font-bold">Add Dev Extension</h2>
<small>
There are 4 options to install an extension in developer mode. Either load it from your local
tarball file, a tarball remote URL, npm package name or load from a remote URL.
</small>
<AddDevExtForm />
</main>

View File

@ -6,8 +6,8 @@
import { ArrowLeftIcon } from "lucide-svelte"
</script>
<svelte:window on:keydown|preventDefault={goBackOnEscape} />
<Button variant="outline" size="icon" class="absolute left-2 top-2 z-50" onclick={goBack}>
<svelte:window on:keydown={goBackOnEscape} />
<Button variant="outline" size="icon" class="fixed left-2 top-2 z-50" onclick={goBack}>
<ArrowLeftIcon class="h-4 w-4" />
</Button>
<div class="absolute left-0 top-0 h-10 w-screen" data-tauri-drag-region></div>

View File

@ -0,0 +1,129 @@
<script lang="ts">
import { goBackOnEscape } from "@/utils/key"
import { goBack } from "@/utils/route"
import { db } from "@kksh/api/commands"
import { loadExtensionManifestFromDisk } from "@kksh/extension"
import { Button, Dialog, ScrollArea, Table } from "@kksh/svelte5"
import { join } from "@tauri-apps/api/path"
import { exists } from "@tauri-apps/plugin-fs"
import { ArrowLeftIcon } from "lucide-svelte"
import { onMount } from "svelte"
import { toast } from "svelte-sonner"
import { open } from "tauri-plugin-shellx-api"
type Result = {
identifier: string
path: string
error?: string
}
let results = $state<Result[]>([])
let isDialogOpen = $state(false)
let errorMsg = $state<string | undefined>()
const sortedResults = $derived.by(() =>
results.slice().sort((a, b) => {
return a.error ? -1 : 1
})
)
async function check() {
results = []
const tmpResults = []
const extensions = await db.getAllExtensions()
for (const ext of extensions) {
if (!ext.path) continue
const _exists = await exists(ext.path)
let error: string | undefined = undefined
if (!_exists) {
error = `Extension path (${ext.path}) does not exist`
}
const pkgJsonPath = await join(ext.path, "package.json")
const _pkgJsonExists = await exists(pkgJsonPath)
if (!_pkgJsonExists) {
error = `Extension package.json (${pkgJsonPath}) does not exist`
}
try {
const manifest = await loadExtensionManifestFromDisk(pkgJsonPath)
} catch (err: any) {
error = `Failed to load manifest from ${pkgJsonPath}: ${err.message}`
}
tmpResults.push({
identifier: ext.identifier,
path: ext.path,
error
})
}
results = tmpResults
const numErrors = results.filter((r) => r.error).length
const toastFn = numErrors > 0 ? toast.error : toast.info
toastFn(`${numErrors} errors found`, {
description: numErrors > 0 ? "Click on an error to see more details" : undefined
})
}
function onErrorClick(errMsg?: string) {
if (errMsg) {
isDialogOpen = true
errorMsg = errMsg
} else {
toast.info("No error message")
}
}
onMount(() => {
check()
})
</script>
<svelte:window on:keydown={goBackOnEscape} />
<Button variant="outline" size="icon" class="absolute left-2 top-2 z-50" onclick={goBack}>
<ArrowLeftIcon class="h-4 w-4" />
</Button>
<div class="absolute left-0 top-0 h-10 w-screen" data-tauri-drag-region></div>
<div class="container pt-10">
<h1 class="text-2xl font-bold">Extension Loading Troubleshooter</h1>
<Button class="my-2" onclick={check}>Check</Button>
<Dialog.Root bind:open={isDialogOpen}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Error Details</Dialog.Title>
</Dialog.Header>
{errorMsg}
</Dialog.Content>
</Dialog.Root>
<Table.Root>
<Table.Caption>A list of your extensions.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head class="">Identifier</Table.Head>
<Table.Head>Path</Table.Head>
<Table.Head>Error</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each sortedResults as row}
<Table.Row>
<Table.Cell class="font-medium"><pre>{row.identifier}</pre></Table.Cell>
<Table.Cell class="">
<button onclick={() => open(row.path)} class="text-left">
<pre class="cursor-pointer text-wrap">{row.path}</pre>
</button>
</Table.Cell>
<Table.Cell class="text-right">
<button onclick={() => onErrorClick(row.error)}>
{row.error ? "⚠️" : "✅"}
</button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<style>
:global(body) {
overflow-x: hidden;
}
</style>

View File

@ -0,0 +1,111 @@
<script lang="ts">
import { winExtMap } from "@/stores"
import { goBackOnEscape, goBackOnEscapeClearSearchTerm } from "@/utils/key"
import { goBack, goHome } from "@/utils/route"
import { getExtLabelMap, unregisterExtensionWindow } from "@kksh/api/commands"
import type { ExtensionLabelMap } from "@kksh/api/models"
import { Button, Checkbox, ScrollArea } from "@kksh/svelte5"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { ArrowLeftIcon, TrashIcon } from "lucide-svelte"
import { onMount } from "svelte"
import { toast } from "svelte-sonner"
const appWin = getCurrentWebviewWindow()
let winLabelMap = $state<ExtensionLabelMap>({})
let refreshEverySecond = $state(true)
let refreshCount = $state(0)
async function refresh() {
const extLabelMap = await getExtLabelMap()
winLabelMap = extLabelMap
refreshCount++
}
function refreshWinLabelMapRecursively() {
setTimeout(async () => {
await refresh()
if (refreshEverySecond) {
refreshWinLabelMapRecursively()
}
}, 1000)
}
onMount(async () => {
const extLabelMap = await getExtLabelMap()
winLabelMap = extLabelMap
refreshCount = 1
})
$effect(() => {
if (refreshEverySecond) {
refreshWinLabelMapRecursively()
}
})
function unregisterWindow(label: string) {
// winExtMap
// .unregisterExtensionFromWindow(label)
unregisterExtensionWindow(label)
.then(() => {
toast.success("Unregistered window")
})
.catch((err) => {
toast.error("Failed to unregister window", { description: err.message })
})
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (appWin.label === "main") {
goHome()
} else {
appWin.close()
}
}
}
</script>
<svelte:window on:keydown={onKeyDown} />
<Button variant="outline" size="icon" class="absolute left-2 top-2 z-50" onclick={goBack}>
<ArrowLeftIcon class="h-4 w-4" />
</Button>
<main class="container h-screen w-screen pt-10">
<div class="flex items-center justify-between space-x-2">
<div class="flex items-center space-x-2">
<Checkbox id="refreshEverySecond" bind:checked={refreshEverySecond} />
<label
for="refreshEverySecond"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Refresh Every Second
</label>
</div>
<span class="flex items-center space-x-2">
<Button size="sm" onclick={refresh}>Refresh</Button>
<span>Refreshed {refreshCount} times</span>
</span>
</div>
<ScrollArea class="py-5" orientation="both">
{#each Object.entries(winLabelMap) as [label, content]}
<li>
<span class="flex gap-2">
<strong>Label:</strong>
<pre class="text-lime">{label}</pre>
</span>
<ul class="pl-5">
{#each Object.entries(content) as [key, value]}
<li>
<span class="flex gap-2">
<strong>{key}:</strong>
<pre class="text-lime">{value}</pre>
</span>
</li>
{/each}
</ul>
<Button variant="destructive" size="icon" onclick={() => unregisterWindow(label)}>
<TrashIcon />
</Button>
</li>
{/each}
</ScrollArea>
</main>

View File

@ -1,3 +1,4 @@
import typography from "@tailwindcss/typography"
import type { Config } from "tailwindcss"
import tailwindcssAnimate from "tailwindcss-animate"
import { fontFamily } from "tailwindcss/defaultTheme"
@ -9,7 +10,7 @@ const config: Config = {
"./node_modules/@kksh/ui/src/**/*.{html,js,svelte,ts}",
"../../node_modules/@kksh/svelte5/src/**/*.{html,js,svelte,ts}"
],
safelist: ["dark"],
safelist: ["dark", "bg-red-500/30"],
theme: {
container: {
center: true,
@ -94,7 +95,7 @@ const config: Config = {
}
}
},
plugins: [tailwindcssAnimate]
plugins: [tailwindcssAnimate, typography]
}
export default config

View File

@ -1,19 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
},
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
"include": ["src/**/*"]
}

View File

@ -0,0 +1,5 @@
import { defineConfig, presetAttributify, presetTagify, presetUno } from "unocss"
export default defineConfig({
presets: [presetUno(), presetAttributify(), presetTagify()]
})

View File

@ -1,4 +1,5 @@
import { sveltekit } from "@sveltejs/kit/vite"
import UnoCSS from "unocss/vite"
import { defineConfig } from "vite"
// @ts-expect-error process is a nodejs global
@ -6,8 +7,7 @@ const host = process.env.TAURI_DEV_HOST
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [sveltekit()],
plugins: [UnoCSS(), sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors

View File

@ -12,7 +12,7 @@
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@kksh/api": "workspace:*",
"@kksh/svelte5": "0.1.2-beta.4",
"@kksh/svelte5": "0.1.2-beta.8",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.7",
"prettier-plugin-tailwindcss": "^0.6.8",

View File

@ -1,7 +1,7 @@
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@kunkun/api",
"version": "0.0.27",
"version": "0.0.28",
"license": "MIT",
"exports": {
".": "./src/index.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@kksh/api",
"version": "0.0.27",
"version": "0.0.28",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@ -26,7 +26,6 @@ export function registerExtensionWindow(options: {
}
export function unregisterExtensionWindow(label: string): Promise<void> {
console.log("unregisterExtensionWindow", label)
return invoke(generateJarvisPluginCommand("unregister_extension_window"), {
label
})

View File

@ -295,7 +295,7 @@ export const rawSystemCommands = [
}
]
export async function getSystemCommands(): Promise<SysCommand[]> {
export function getSystemCommands(): SysCommand[] {
return rawSystemCommands
.filter(async (cmd) => cmd.platforms.includes(platform())) // Filter out system commands that are not supported on the current platform
.map((cmd) => ({

View File

@ -7,7 +7,7 @@ export enum KUNKUN_EXT_IDENTIFIER {
}
export const KUNKUN_DESKTOP_APP_SERVER_PORTS = [1566, 1567, 1568, 9559, 9560, 9561]
export const DESKTOP_SERVICE_NAME = "Kunkun"
export const DESKTOP_SERVICE_NAME = "kunkun"
/* -------------------------------------------------------------------------- */
/* Deep Link */

View File

@ -15,7 +15,7 @@ export function checkLocalKunkunService(port: number): Promise<boolean> {
return res.json()
})
.then((data) => {
return data["service_name"] === DESKTOP_SERVICE_NAME
return data["service_name"].toLowerCase() === DESKTOP_SERVICE_NAME.toLowerCase()
})
.catch((err) => {
// fetch fail, i.e. server not on this port

View File

@ -41,6 +41,8 @@ export type Ext = InferOutput<typeof Ext>
export enum CmdTypeEnum {
HeadlessWorker = "headless_worker",
Builtin = "builtin",
System = "system",
UiWorker = "ui_worker",
UiIframe = "ui_iframe",
QuickLink = "quick_link",
@ -55,12 +57,18 @@ export const ExtCmd = object({
name: string(),
type: CmdType,
data: string(),
alias: optional(string()),
hotkey: optional(string()),
alias: nullable(optional(string())),
hotkey: nullable(optional(string())),
enabled: boolean()
})
export type ExtCmd = InferOutput<typeof ExtCmd>
export const QuickLinkCmd = object({
...ExtCmd.entries,
data: object({ link: string(), icon: Icon })
})
export type QuickLinkCmd = InferOutput<typeof QuickLinkCmd>
export const ExtData = object({
dataId: number(),
extId: number(),

View File

@ -1,4 +1,13 @@
import { enum_, literal, object, string, type InferOutput } from "valibot"
import {
boolean,
enum_,
literal,
nullable,
object,
optional,
string,
type InferOutput
} from "valibot"
import { NodeName, NodeNameEnum } from "./constants"
/* -------------------------------------------------------------------------- */
@ -16,7 +25,8 @@ export type IconType = InferOutput<typeof IconType>
export const Icon = object({
type: IconType,
value: string()
value: string(),
invert: optional(boolean())
})
export type Icon = InferOutput<typeof Icon>
export const IconNode = object({

View File

@ -33,6 +33,7 @@ import type { fileSearch } from "../commands/fileSearch"
import { type AppInfo } from "../models/apps"
import type { LightMode, Position, Radius, ThemeColor } from "../models/styles"
import type { DenoSysOptions } from "../permissions/schema"
import type { MarkdownSchema } from "./worker"
import { type IComponent } from "./worker/components/interfaces"
import type { Markdown } from "./worker/components/markdown"
import * as FormSchema from "./worker/schema/form"
@ -116,7 +117,7 @@ export interface IToast {
}
export interface IUiWorker {
render: (view: IComponent<ListSchema.List | FormSchema.Form | Markdown>) => Promise<void>
render: (view: IComponent<ListSchema.List | FormSchema.Form | MarkdownSchema>) => Promise<void>
goBack: () => Promise<void>
showLoadingBar: (loading: boolean) => Promise<void>
setScrollLoading: (loading: boolean) => Promise<void>

View File

@ -145,6 +145,9 @@ export class Form implements IComponent<FormSchema.Form> {
constructor(model: OmitNodeName<FormSchema.Form & { fields: (AllFormFields | Form)[] }>) {
this.fields = model.fields
this.key = model.key
this.title = model.title
this.description = model.description
this.submitBtnText = model.submitBtnText
}
toModel(): FormSchema.Form {

View File

@ -65,7 +65,8 @@ export type BaseField = InferOutput<typeof BaseField>
export const InputField = object({
...BaseField.entries,
type: optional(InputTypes),
component: optional(union([literal("textarea"), literal("default")]))
component: optional(union([literal("textarea"), literal("default")])),
default: optional(string())
})
export type InputField = InferOutput<typeof InputField>
@ -74,7 +75,8 @@ export type InputField = InferOutput<typeof InputField>
/* -------------------------------------------------------------------------- */
export const NumberField = object({
...BaseField.entries,
nodeName: FormNodeName
nodeName: FormNodeName,
default: optional(number())
})
export type NumberField = InferOutput<typeof NumberField>
@ -84,7 +86,8 @@ export type NumberField = InferOutput<typeof NumberField>
// with zod enum
export const SelectField = object({
...BaseField.entries,
options: array(string())
options: array(string()),
default: optional(string())
})
export type SelectField = InferOutput<typeof SelectField>
@ -101,7 +104,8 @@ export type BooleanField = InferOutput<typeof BooleanField>
/* Date */
/* -------------------------------------------------------------------------- */
export const DateField = object({
...BaseField.entries
...BaseField.entries,
default: optional(string())
})
export type DateField = InferOutput<typeof DateField>
@ -121,14 +125,22 @@ export type ArrayField = InferOutput<typeof ArrayField>
/* -------------------------------------------------------------------------- */
export const FormField = union([
ArrayField, // this must be placed first, otherwise its content field won't be parsed
SelectField,
InputField,
NumberField,
SelectField,
BooleanField,
DateField
])
export type FormField = InferOutput<typeof FormField>
// export type Form = InferOutput<typeof Form>
export const Form: GenericSchema<Form> = object({
nodeName: FormNodeName,
key: string(),
fields: array(union([lazy(() => Form), FormField])),
title: optional(string()),
description: optional(string()),
submitBtnText: optional(string())
})
export type Form = {
nodeName: FormNodeName
title?: string
@ -137,8 +149,3 @@ export type Form = {
key: string
fields: (FormField | Form)[]
}
export const Form: GenericSchema<Form> = object({
nodeName: FormNodeName,
key: string(),
fields: array(union([lazy(() => Form), FormField]))
})

View File

@ -13,7 +13,7 @@ export const breakingChangesVersionCheckpoints = [
const checkpointVersions = breakingChangesVersionCheckpoints.map((c) => c.version)
const sortedCheckpointVersions = sort(checkpointVersions)
export const version = "0.0.27"
export const version = "0.0.28"
export function isVersionBetween(v: string, start: string, end: string) {
const vCleaned = clean(v)

View File

@ -1,5 +1,13 @@
import { db } from "@kksh/api/commands"
import { ExtPackageJson, ExtPackageJsonExtra } from "@kksh/api/models"
import {
CmdTypeEnum,
ExtCmd,
ExtPackageJson,
ExtPackageJsonExtra,
Icon,
QuickLinkCmd
} from "@kksh/api/models"
import * as v from "valibot"
export async function upsertExtension(extPkgJson: ExtPackageJson, extFullPath: string) {
const extInDb = await db.getUniqueExtensionByIdentifier(extPkgJson.kunkun.identifier)
@ -12,3 +20,38 @@ export async function upsertExtension(extPkgJson: ExtPackageJson, extFullPath: s
})
}
}
export async function createQuickLinkCommand(name: string, link: string, icon: Icon) {
const extension = await db.getExtQuickLinks()
return db.createCommand({
extId: extension.extId,
name,
cmdType: CmdTypeEnum.QuickLink,
data: JSON.stringify({
link,
icon
}),
enabled: true
})
}
export async function getAllQuickLinkCommands(): Promise<QuickLinkCmd[]> {
const extension = await db.getExtQuickLinks()
const cmds = await db.getCommandsByExtId(extension.extId)
return cmds
.map((cmd) => {
try {
cmd.data = JSON.parse(cmd.data)
const parsedData = v.safeParse(QuickLinkCmd, cmd)
if (!parsedData.success) {
console.warn("Fail to parse quick link command", cmd)
console.error(v.flatten(parsedData.issues))
return null
}
return parsedData.output
} catch (error) {
return null
}
})
.filter((cmd) => cmd !== null)
}

View File

@ -88,6 +88,11 @@ export async function installTarballUrl(tarballUrl: string, extsDir: string): Pr
}
}
/**
* Install dev extension from a local directory
* @param extPath Path to the extension directory
* @returns
*/
export async function installDevExtensionDir(extPath: string): Promise<ExtPackageJsonExtra> {
const manifestPath = await path.join(extPath, "package.json")
if (!(await fs.exists(manifestPath))) {

View File

@ -0,0 +1,176 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
extensions_support/

View File

@ -0,0 +1,8 @@
# demo-template-extension
## 0.0.3
### Patch Changes
- Updated dependencies
- @kksh/api@0.0.9

View File

@ -0,0 +1,15 @@
# tempalte-ext-worker
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.1.20. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View File

@ -0,0 +1,3 @@
import Buffer from "node:buffer"
console.log(Buffer)

View File

@ -0,0 +1,31 @@
import { watch } from "fs"
import { join } from "path"
import { refreshTemplateWorkerExtension } from "@kksh/api/dev"
import { $ } from "bun"
async function build() {
try {
// await $`bun build --minify --target=browser --outdir=./dist ./src/index.ts`
const output = await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
minify: true,
target: "browser"
})
console.log(output)
await refreshTemplateWorkerExtension()
} catch (error) {
console.error(error)
}
}
const srcDir = join(import.meta.dir, "src")
await build()
if (Bun.argv.includes("dev")) {
console.log(`Watching ${srcDir} for changes...`)
watch(srcDir, { recursive: true }, async (event, filename) => {
await build()
})
}

View File

@ -0,0 +1,5 @@
{
"imports": {
"@kunkun/api": "jsr:@kunkun/api@^0.0.14"
}
}

View File

@ -0,0 +1,23 @@
{
"version": "4",
"specifiers": {
"jsr:@hk/comlink-stdio@~0.1.5": "0.1.5",
"jsr:@kunkun/api@^0.0.14": "0.0.14"
},
"jsr": {
"@hk/comlink-stdio@0.1.5": {
"integrity": "1fd67d5d53ab4571e745584d66b480b5be402f6ca6b2c9e591230fa1d23f85ee"
},
"@kunkun/api@0.0.14": {
"integrity": "a21a255748164992ca93fc292451677261dffca336922a6bed7eb8703c6e880b",
"dependencies": [
"jsr:@hk/comlink-stdio"
]
}
},
"workspace": {
"dependencies": [
"jsr:@kunkun/api@^0.0.14"
]
}
}

View File

@ -0,0 +1,13 @@
import { expose } from "@kunkun/api/runtime/deno"
export interface API {
add(a: number, b: number): Promise<number>
subtract(a: number, b: number): Promise<number>
}
// Define your API methods
export const apiMethods: API = {
add: async (a: number, b: number) => a + b,
subtract: async (a: number, b: number) => a - b
}
expose(apiMethods)

View File

@ -0,0 +1,114 @@
{
"$schema": "../../schema/manifest-json-schema.json",
"name": "demo-template-extension",
"version": "0.0.3",
"type": "module",
"kunkun": {
"name": "Demo Template Extension",
"shortDescription": "Demo Template Extension",
"longDescription": "Demo Template Extension",
"identifier": "demo-worker-template-ext",
"permissions": [
"fetch:all",
"shell:kill",
"security:mac:all",
{
"permission": "shell:deno:execute",
"allow": [
{
"path": "$EXTENSION/deno-src/deno-script.ts",
"env": [
"npm_package_config_libvips",
"CWD"
],
"ffi": "*",
"read": [
"$DESKTOP"
]
},
{
"path": "$EXTENSION/deno-src/rpc.ts",
"ffi": "*"
}
]
},
{
"permission": "open:file",
"allow": [
{
"path": "$EXTENSION/src/deno-script.ts"
}
]
},
"shell:stdin-write",
{
"permission": "shell:execute",
"allow": [
{
"cmd": {
"program": "ls",
"args": [
"-l"
]
}
},
{
"cmd": {
"program": "bash",
"args": [
"-c",
".+"
]
}
},
{
"cmd": {
"program": "deno",
"args": [
"-A",
".+",
".+"
]
}
}
]
}
],
"demoImages": [],
"icon": {
"type": "iconify",
"value": "carbon:demo"
},
"customUiCmds": [],
"templateUiCmds": [
{
"name": "Demo Worker Template",
"main": "dist/index.js",
"cmds": []
}
]
},
"scripts": {
"dev": "bun build.ts dev",
"build": "bun build.ts"
},
"dependencies": {
"@hk/comlink-stdio": "npm:@jsr/hk__comlink-stdio@^0.1.6",
"@kksh/api": "workspace:*",
"@kunkun/api": "npm:@jsr/kunkun__api@^0.0.13"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.6",
"@types/bun": "latest",
"rollup-plugin-visualizer": "^5.12.0"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"files": [
"./dist",
".gitignore"
]
}

View File

@ -0,0 +1,20 @@
import { visualizer } from "rollup-plugin-visualizer";
import resolve from '@rollup/plugin-node-resolve';
import typescript from "@rollup/plugin-typescript";
import commonjs from '@rollup/plugin-commonjs';
export default {
input: "src/index.ts",
output: {
dir: "dist",
format: "esm",
},
plugins: [
typescript(),
resolve(),
commonjs(),
// put it the last one
visualizer(),
],
};

View File

@ -0,0 +1,181 @@
import type { RPCChannel } from "@hk/comlink-stdio/browser"
import {
Action,
app,
Child,
expose,
Form,
fs,
Icon,
IconEnum,
List,
Markdown,
open,
path,
security,
shell,
toast,
ui,
WorkerExtension
} from "@kksh/api/ui/worker"
import { IconType } from "@kunkun/api/models"
const nums = Array.from({ length: 20 }, (_, i) => i + 1)
const categories = ["Suggestion", "Advice", "Idea"]
const itemsTitle = nums.map((n) => categories.map((c) => `${c} ${n}`)).flat()
const allItems: List.Item[] = itemsTitle.map(
(title) =>
new List.Item({
title,
value: title,
defaultAction: "Item Default Action"
})
)
class ExtensionTemplate extends WorkerExtension {
async onBeforeGoBack() {
console.log("onBeforeGoBack")
// console.log(`Try killing pid: ${this.apiProcess?.pid}`)
// await this.apiProcess?.kill()
// console.log("apiProcess killed")
}
async onFormSubmit(value: Record<string, any>): Promise<void> {
console.log("Form submitted", value)
}
async onEnterPressedOnSearchBar(): Promise<void> {
console.log("Enter pressed on search bar")
}
async load() {
// console.log("Check screen capture permission:", await security.mac.checkScreenCapturePermission())
// await security.mac.revealSecurityPane("AllFiles")
// console.log(await security.mac.verifyFingerprint())
ui.setSearchBarPlaceholder("Search for items")
ui.showLoadingBar(true)
setTimeout(() => {
ui.showLoadingBar(false)
}, 2000)
const { rpcChannel, process } = await shell.createDenoRpcChannel<
{},
{
add(a: number, b: number): Promise<number>
subtract(a: number, b: number): Promise<number>
}
>("$EXTENSION/deno-src/rpc.ts", [], {}, {})
const api = rpcChannel.getApi()
await api.add(1, 2).then(console.log)
await api.subtract(1, 2).then(console.log)
await process.kill()
const extPath = await path.extensionDir()
// console.log("Extension path:", extPath)
const tagList = new List.ItemDetailMetadataTagList({
title: "Tag List Title",
tags: [
new List.ItemDetailMetadataTagListItem({
text: "red",
color: "#ff0000"
}),
new List.ItemDetailMetadataTagListItem({
text: "yellow",
color: "#ffff00"
})
]
})
const list = new List.List({
items: allItems,
defaultAction: "Top Default Action",
detail: new List.ItemDetail({
children: [
new List.ItemDetailMetadata([
new List.ItemDetailMetadataLabel({
title: "Label Title",
text: "Label Text"
}),
new List.ItemDetailMetadataLabel({
title: "Label Title",
text: "Label Text",
icon: new Icon({
type: IconType.enum.Iconify,
value: "mingcute:appstore-fill"
})
}),
new List.ItemDetailMetadataSeparator(),
new List.ItemDetailMetadataLabel({
title: "Label Title",
text: "Label Text"
}),
new List.ItemDetailMetadataLink({
title: "Link Title",
text: "Link Text",
url: "https://github.com/huakunshen"
}),
new List.ItemDetailMetadataLabel({
title: "Label Title",
text: "Label Text"
}),
tagList
]),
new Markdown(`
# Hello World
<img src="https://github.com/huakunshen.png" />
<img src="https://github.com/huakunshen.png" />
<img src="https://github.com/huakunshen.png" />
`)
],
width: 50
}),
actions: new Action.ActionPanel({
items: [
new Action.Action({
title: "Action 1",
value: "action 1",
icon: new Icon({ type: IconType.enum.Iconify, value: "material-symbols:add-reaction" })
}),
new Action.Action({ title: "Action 2", value: "action 2" }),
new Action.Action({ title: "Action 3", value: "action 3" }),
new Action.Action({ title: "Action 4", value: "action 4" })
]
})
})
return ui.render(list)
}
async onSearchTermChange(term: string): Promise<void> {
return ui.render(
new List.List({
// items: allItems.filter((item) => item.title.toLowerCase().includes(term.toLowerCase())),
inherits: ["items", "sections"],
defaultAction: "Top Default Action",
detail: new List.ItemDetail({
children: [
new List.ItemDetailMetadata([
new List.ItemDetailMetadataLabel({
title: "Label Title",
text: "Label Text"
})
])
// new Markdown(`
// ## Search results for "${term}"
// <img src="https://github.com/huakunshen.png" />
// <img src="https://github.com/huakunshen.png" />
// <img src="https://github.com/huakunshen.png" />
// `)
],
width: term.length > 3 ? 70 : 30
})
})
)
}
async onListItemSelected(value: string): Promise<void> {
console.log("Item selected:", value)
}
async onActionSelected(value: string): Promise<void> {
console.log("Action selected:", value)
}
}
expose(new ExtensionTemplate())

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

177
packages/extensions/form-view/.gitignore vendored Normal file
View File

@ -0,0 +1,177 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
extensions_support/

View File

@ -0,0 +1,125 @@
# Kunkun Template UI Extension
This is a template for a template UI extension. (UI follows pre-defined template)
[./src/index.ts](./src/index.ts) is the default entrypoint for the extension. You can import any other files in this file, but the build process will bundle them into a single file.
## Pros and Cons
This type of extension is suitable for simple use cases, such as a list or form. All components are pre-defined, so there is not much room for customization. If you want more flexibility on the UI, consider using [Custom UI Extension](https://docs.kunkun.sh/extensions/custom-ui-ext/), which requires some frontend knowledge but gives you full control over the UI.
Read documentation at https://docs.kunkun.sh/extensions/worker-template/
Make sure you understand what this type of extension is capable of.
### Pros
- Simple to develop, no need for any frontend knowledge.
- Small bundle size (~40KB)
- [Custom UI Extension](https://docs.kunkun.sh/extensions/custom-ui-ext/) are usually larger than 300KB.
### Cons
- Limited UI customization. Not suitable for complex use cases.
Consider [Custom UI Extension](https://docs.kunkun.sh/extensions/custom-ui-ext/) if you need more complex UI.
## Development
```bash
pnpm install
```
Start extension in development mode. Every save will trigger a hot reload in Kunkun.
```bash
pnpm dev
```
- During development, right click in Kunkun to open the developer tools.
- Error messages will be shown in the console.
- If you got any permission error while calling Kunknu's APIs, make sure you've declared the permission in `package.json`. Then go back to home page and enter the extension again to re-apply the permission.
- To develop and preview the extension in Kunkun, you need to run the `Add Dev Extension` command in Kunkun, and register this extension's path.
Build the extension. Your extension source code can contain many files, but the build process will bundle them into a single file.
```bash
pnpm build
# Due to Bun's bug, if you are on windows, and install dependencies with pnpm, you may get error during build.
# Try install dependencies with bun or npm instead.
```
## i18n
[./src/i18n](./src/i18n/) contains optional internationalization support starter code.
If you want to support i18n, you can use the `t` function to translate the strings in the extension.
User's language setting is available via `app.language()`.
```ts
import { app } from "@kksh/api/ui/worker"
import { setupI18n, t } from "./src/i18n"
setupI18n("zh")
console.log(t("welcome"))
setupI18n(await app.language())
console.log(t("welcome"))
```
## Add More Commands
If you want to add more template worker extension commands, simply modify the `entrypoints` array in [./build.ts](./build.ts).
Then in `package.json`, register the new command.
## Verify Build and Publish
```bash
pnpm build # make sure the build npm script works
npx kksh@latest verify # Verify some basic settings
npx kksh@latest verify --publish # Verify some basic settings before publishing
```
It is recommended to build the extension with the same environment our CI uses.
The docker image used by our CI is `huakunshen/kunkun-ext-builder:latest`.
You can use the following command to build the extension with the same environment our CI uses.
This requires you to have docker installed, and the shell you are using has access to it via `docker` command.
```bash
npx kksh@latest build # Build the extension with
```
`pnpm` is used to install dependencies and build the extension.
The docker image environment also has `node`, `pnpm`, `npm`, `bun`, `deno` installed.
If your build failed, try debug with `huakunshen/kunkun-ext-builder:latest` image in interative mode and bind your extension volume to `/workspace`.
After build successfully, you should find a tarball file ends with `.tgz` in the root of your extension.
The tarball is packaged with `npm pack` command. You can uncompress it to see if it contains all the necessary files.
This tarball is the final product that will be published and installed in Kunkun. You can further verify your extension by installing this tarball directly in Kunkun.
After verifying the tarball, it's ready to be published.
Fork [KunkunExtensions](https://github.com/kunkunsh/KunkunExtensions) repo, add your extension to the `extensions` directory, and create a PR.
Once CI passed and PR merged, you can use your extension in Kunkun.
## Potential Error
Our CI uses `pnpm` to install dependencies. If you are on Windows, you may get error during build.
See issue https://github.com/kunkunsh/kunkun/issues/78
`bun` had problem building the extension when `pnpm` is used to install dependencies.
### Options
1. Install an older version of `bun` (1.1.27 should work)
2. Install dependencies with `bun` or `npm` instead of `pnpm`
Our CI always builds the extension with on Linux and shouldn't have this problem.

View File

@ -0,0 +1,30 @@
import { watch } from "fs"
import { join } from "path"
import { refreshTemplateWorkerExtension } from "@kksh/api/dev"
import { $ } from "bun"
const entrypoints = ["./src/index.ts"]
async function build() {
try {
for (const entrypoint of entrypoints) {
await $`bun build --minify --target=browser --outdir=./dist ${entrypoint}`
}
if (Bun.argv.includes("dev")) {
await refreshTemplateWorkerExtension()
}
} catch (error) {
console.error(error)
}
}
const srcDir = join(import.meta.dir, "src")
await build()
if (Bun.argv.includes("dev")) {
console.log(`Watching ${srcDir} for changes...`)
watch(srcDir, { recursive: true }, async (event, filename) => {
await build()
})
}

View File

@ -0,0 +1,47 @@
{
"$schema": "https://schema.kunkun.sh",
"name": "form-view",
"version": "0.0.2",
"type": "module",
"kunkun": {
"name": "Form View",
"shortDescription": "A Worker Extension Template",
"longDescription": "A Worker Extension Template",
"identifier": "form-view",
"permissions": [
"fetch:all",
"clipboard:read-all"
],
"demoImages": [],
"icon": {
"type": "iconify",
"value": "fluent:form-multiple-28-filled"
},
"customUiCmds": [],
"templateUiCmds": [
{
"name": "Dev Form View",
"main": "dist/index.js",
"cmds": []
}
]
},
"scripts": {
"dev": "bun build.ts dev",
"build": "bun build.ts"
},
"dependencies": {
"@kksh/api": "workspace:*",
"i18next": "^23.15.1"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"files": [
"./dist",
".gitignore"
]
}

View File

@ -0,0 +1,5 @@
const en = {
welcome: "Welcome to Kunkun"
}
export default en
export type Translation = typeof en

View File

@ -0,0 +1,20 @@
import i18next from "i18next"
import en, { type Translation } from "./en"
import zh from "./zh"
export function setupI18n(language: "en" | "zh" = "en") {
i18next.init({
resources: {
en: {
translation: en
},
zh: {
translation: zh
}
},
lng: language, // default language
fallbackLng: "en"
})
}
export const t = (key: keyof Translation, options?: any) => i18next.t(key, options)

View File

@ -0,0 +1,5 @@
import type { Translation } from "./en"
export default {
welcome: "欢迎来到Kunkun"
} satisfies Translation

View File

@ -0,0 +1,95 @@
import {
Action,
app,
expose,
Form,
fs,
Icon,
IconEnum,
List,
Markdown,
path,
shell,
toast,
ui,
WorkerExtension
} from "@kksh/api/ui/worker"
class ExtensionTemplate extends WorkerExtension {
async onFormSubmit(value: Record<string, any>): Promise<void> {
console.log("Form submitted", value)
toast.success(`Form submitted: ${JSON.stringify(value)}`)
}
async load() {
const markdown = new Markdown(`# Hello World
<img src="https://github.com/huakunshen.png" />`)
// markdown.toModel
return ui.render(markdown)
const form = new Form.Form({
title: "Form 1",
key: "form1",
submitBtnText: "Download",
fields: [
new Form.DateField({
key: "birthday",
label: "Date of Birth",
hideLabel: false,
description: "Enter your date of birth"
}),
new Form.NumberField({
key: "age",
label: "Age",
default: 18,
placeholder: "Enter your age",
optional: true,
description: "Enter your age"
}),
new Form.InputField({
key: "name",
label: "Name",
default: "Huakun"
}),
new Form.InputField({
key: "name2",
label: "Name 2"
}),
new Form.BooleanField({
key: "isActive",
label: "Is Active",
description: "Is the user active?"
}),
new Form.SelectField({
key: "gender",
label: "Gender",
options: ["Male", "Female", "Other"],
description: "Select your gender"
})
]
})
console.log(form)
console.log(form.toModel())
return ui.render(form)
}
async onActionSelected(actionValue: string): Promise<void> {
switch (actionValue) {
case "open":
break
default:
break
}
}
onSearchTermChange(term: string): Promise<void> {
console.log("Search term changed to:", term)
return Promise.resolve()
}
onListItemSelected(value: string): Promise<void> {
console.log("Item selected:", value)
return Promise.resolve()
}
}
expose(new ExtensionTemplate())

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@ -15,8 +15,7 @@
},
"devDependencies": {
"@gcornut/valibot-json-schema": "^0.42.0",
"@types/bun": "latest",
"supabase": ">=1.8.1"
"@types/bun": "latest"
},
"peerDependencies": {
"@kksh/supabase": "workspace:*",
@ -25,7 +24,6 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.583.0",
"@kksh/api": "workspace:*",
"@supabase/supabase-js": "^2.43.4",
"valibot": "^0.40.0"
}
}

View File

@ -1,10 +1,9 @@
import { ExtPackageJson } from "@kksh/api/models"
import { type Database } from "@kksh/supabase"
import { createClient } from "@supabase/supabase-js"
import { createSB } from "@kksh/supabase"
import { parse, string } from "valibot"
import { getJsonSchema } from "../src"
const supabase = createClient<Database>(
const supabase = createSB(
parse(string(), process.env.SUPABASE_URL),
parse(string(), process.env.SUPABASE_SERVICE_ROLE_KEY)
)

View File

@ -8,7 +8,8 @@
".": "./src/index.ts"
},
"dependencies": {
"@kksh/api": "workspace:*"
"@kksh/api": "workspace:*",
"@supabase/supabase-js": "^2.46.1"
},
"devDependencies": {
"@types/bun": "latest"

View File

@ -2,7 +2,11 @@ import type { Database } from "@kksh/api/supabase/types"
import { createClient } from "@supabase/supabase-js"
export function createSB(supabaseUrl: string, supabaseAnonKey: string) {
return createClient<Database>(supabaseUrl, supabaseAnonKey)
return createClient<Database>(supabaseUrl, supabaseAnonKey, {
auth: {
flowType: "pkce"
}
})
}
export { SupabaseAPI } from "./api"

View File

@ -23,6 +23,6 @@ export type PersistedAppConfig = v.InferOutput<typeof PersistedAppConfig>
export type AppConfig = PersistedAppConfig & {
isInitialized: boolean
extensionPath?: string
extensionsInstallDir?: string
platform: Platform
}

View File

@ -1,4 +1,9 @@
import { Action as ActionSchema } from "@kksh/api/models"
export interface AppState {
searchTerm: string
highlightedCmd: string
loadingBar: boolean
defaultAction: string
actionPanel?: ActionSchema.ActionPanel
}

View File

@ -8,10 +8,10 @@
},
"aliases": {
"components": "@kksh/ui/src/components",
"utils": "@kksh/ui/src/utils",
"utils": "@kksh/ui/utils",
"ui": "@kksh/ui/src/components/ui",
"hooks": "@kksh/ui/src/hooks"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
}
}

View File

@ -34,24 +34,31 @@
"lint": "eslint ."
},
"devDependencies": {
"@iconify/svelte": "^4.0.2",
"@kksh/api": "workspace:*",
"@kksh/svelte5": "^0.1.2-beta.8",
"@types/bun": "latest",
"bits-ui": "1.0.0-next.36",
"bits-ui": "1.0.0-next.45",
"clsx": "^2.1.1",
"formsnap": "2.0.0-next.1",
"lucide-svelte": "^0.454.0",
"mode-watcher": "^0.4.1",
"paneforge": "1.0.0-next.1",
"shiki": "^1.22.2",
"svelte-radix": "^2.0.1",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.20.0",
"tailwind-merge": "^2.5.4",
"tailwind-variants": "^0.2.1",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.6",
"@std/semver": "npm:@jsr/std__semver@^1.0.3",
"gsap": "^3.12.5"
"gsap": "^3.12.5",
"svelte-markdown": "^0.4.1"
}
}

View File

@ -12,17 +12,37 @@
</script>
{#if icon.type === IconEnum.RemoteUrl}
<img loading="lazy" class={cn("", className)} src={icon.value} alt="" {...restProps} />
<img
loading="lazy"
class={cn("", className, { invert: icon.invert })}
src={icon.value}
alt=""
{...restProps}
/>
{:else if icon.type === IconEnum.Iconify}
<Icon icon={icon.value} class={cn("", className)} {...restProps} />
<Icon icon={icon.value} class={cn("", className, { invert: icon.invert })} {...restProps} />
{:else if icon.type === IconEnum.Base64PNG}
<img loading="lazy" src="data:image/png;base64, {icon.value}" alt="" {...restProps} />
<img
class={cn(className, { invert: icon.invert })}
loading="lazy"
src="data:image/png;base64, {icon.value}"
alt=""
{...restProps}
/>
{:else if icon.type === IconEnum.Text}
<Button class={cn("shrink-0 text-center", className)} size="icon" {...restProps}>
<Button
class={cn("shrink-0 text-center", className, { invert: icon.invert })}
size="icon"
{...restProps}
>
{icon.value}
</Button>
{:else if icon.type === IconEnum.Svg}
<span {...restProps}>{@html icon.value}</span>
<span {...restProps} class={cn(className, { invert: icon.invert })}>{@html icon.value}</span>
{:else}
<Icon icon="mingcute:appstore-fill" class={cn("", className)} {...restProps} />
<Icon
icon="mingcute:appstore-fill"
class={cn("", className, { invert: icon.invert })}
{...restProps}
/>
{/if}

View File

@ -0,0 +1,56 @@
<script lang="ts">
import { Icon, IconEnum, IconType } from "@kksh/api/models"
import { Button, Checkbox, Label, Select, Textarea } from "@kksh/svelte5"
import { cn } from "@kksh/ui/utils"
import { open } from "tauri-plugin-shellx-api"
import IconMultiplexer from "./IconMultiplexer.svelte"
let { icon = $bindable<Icon>(), class: className }: { icon?: Icon; class?: string } = $props()
const iconOptions: Record<string, IconType> = {
"Remote Url": IconEnum.RemoteUrl,
Iconify: IconEnum.Iconify,
Svg: IconEnum.Svg,
"Base64 PNG": IconEnum.Base64PNG
}
const iconOptionsArray = $derived(Object.entries(iconOptions))
const triggerContent = $derived(
iconOptionsArray.find(([_, value]) => value === icon.type)?.[0] ?? "Select a fruit"
)
</script>
<div class="flex flex-col gap-2">
<Select.Root type="single" name="icontype" bind:value={icon.type}>
<Select.Trigger class="w-[180px]">
{triggerContent}
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.GroupHeading>Icon Type</Select.GroupHeading>
{#each iconOptionsArray as [label, value]}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
<Textarea bind:value={icon.value} placeholder="Icon Value" />
{#if icon.type === IconEnum.Iconify}
<Button onclick={() => open("https://icon-sets.iconify.design/")} size="sm" variant="secondary">
Pick Iconify icon name
</Button>
{/if}
<div class="flex items-center space-x-2">
<Checkbox id="terms" bind:checked={icon.invert} aria-labelledby="terms-label" />
<Label
id="terms-label"
for="terms"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Invert Icon Color
</Label>
</div>
<h2 class="font-semibold">Icon Preview</h2>
{#if icon.type && icon.value && icon.value.length > 0}
<IconMultiplexer class="h-12 w-12" {icon} />
{/if}
</div>

View File

@ -0,0 +1,75 @@
<script lang="ts">
import { Icon, IconEnum, IconType } from "@kksh/api/models"
import { Button, ButtonModule, Dialog, Input, Label, Select } from "@kksh/svelte5"
import { cn } from "@kksh/ui/utils"
import { ImageIcon } from "lucide-svelte"
import IconMultiplexer from "./IconMultiplexer.svelte"
const { icon, class: className }: { icon?: Icon; class?: string } = $props()
function onClick(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
console.log("clicked")
}
let iconType = $state<string>(icon?.type ?? IconEnum.Iconify)
const iconOptions: Record<string, IconType> = {
Iconify: IconEnum.Iconify,
"Remote Url": IconEnum.RemoteUrl,
Svg: IconEnum.Svg,
"Base64 PNG": IconEnum.Base64PNG,
Text: IconEnum.Text
}
const iconOptionsArray = $derived(Object.entries(iconOptions))
const triggerContent = $derived(
iconOptionsArray.find(([_, value]) => value === iconType)?.[0] ?? "Select a fruit"
)
</script>
<button class={cn("block h-12 w-12", className)} onclick={onClick}>
{#if icon}
<IconMultiplexer {icon} />
{:else}
<ImageIcon class="h-full w-full" />
{/if}
</button>
<Dialog.Root open={true}>
<Dialog.Trigger class={ButtonModule.buttonVariants({ variant: "outline" })}>
Select Icon
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Select Icon</Dialog.Title>
<!-- <Dialog.Description></Dialog.Description> -->
</Dialog.Header>
<Select.Root type="single" name="icontype" bind:value={iconType}>
<Select.Trigger class="w-[180px]">
{triggerContent}
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.GroupHeading>Fruits</Select.GroupHeading>
{#each iconOptionsArray as [label, value]}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
<!-- <div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="name" class="text-right">Name</Label>
<Input id="name" value="Pedro Duarte" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="username" class="text-right">Username</Label>
<Input id="username" value="@peduarte" class="col-span-3" />
</div>
</div> -->
<Dialog.Footer>
<Button type="submit">Save</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

Some files were not shown because too many files have changed in this diff Show More