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

View File

@ -8,47 +8,14 @@ pub fn tauri_file_server(
extension_folder_path: PathBuf, extension_folder_path: PathBuf,
dist: Option<String>, dist: Option<String>,
) -> tauri::http::Response<Vec<u8>> { ) -> 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 = &request.uri().path()[1..]; // skip the first /
let path = urlencoding::decode(path).unwrap().to_string(); let path = urlencoding::decode(path).unwrap().to_string();
let mut url_file_path = extension_folder_path; let mut url_file_path = extension_folder_path;
// .join(ext_identifier)
match dist { match dist {
Some(dist) => url_file_path = url_file_path.join(dist), Some(dist) => url_file_path = url_file_path.join(dist),
None => {} None => {}
} }
url_file_path = url_file_path.join(path); 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 // 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() { if url_file_path.is_file() {
// println!("1st case url_file_path: {:?}", url_file_path); // 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 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 { goto } from "$app/navigation"
import { toast } from "svelte-sonner"
import { v4 as uuidv4 } from "uuid"
export const builtinCmds: BuiltinCmd[] = [ export const builtinCmds: BuiltinCmd[] = [
{ {
@ -13,58 +18,59 @@ export const builtinCmds: BuiltinCmd[] = [
goto("/extension/store") goto("/extension/store")
} }
}, },
// { {
// name: "Sign In", name: "Sign In",
// iconifyIcon: "mdi:login-variant", iconifyIcon: "mdi:login-variant",
// description: "", description: "",
// function: async () => { function: async () => {
// goto("/auth") goto("/auth")
// } }
// }, },
// { {
// name: "Sign Out", name: "Sign Out",
// iconifyIcon: "mdi:logout-variant", iconifyIcon: "mdi:logout-variant",
// description: "", description: "",
// function: async () => { function: async () => {
// const supabase = useSupabaseClient() auth
// supabase.auth.signOut() .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: "", name: "Show Draggable Area",
// function: async () => { iconifyIcon: "mingcute:move-fill",
// // select all html elements with attribute data-tauri-drag-region description: "",
// const elements = document.querySelectorAll("[data-tauri-drag-region]") function: async () => {
// elements.forEach((el) => { // select all html elements with attribute data-tauri-drag-region
// el.classList.add("bg-red-500/30") const elements = document.querySelectorAll("[data-tauri-drag-region]")
// }) elements.forEach((el) => {
// setTimeout(() => { el.classList.add("bg-red-500/30")
// elements.forEach((el) => { })
// el.classList.remove("bg-red-500/30") setTimeout(() => {
// }) elements.forEach((el) => {
// }, 2_000) el.classList.remove("bg-red-500/30")
// } })
// }, }, 2_000)
// { }
// name: "Add Dev Extension", },
// iconifyIcon: "lineicons:dev", {
// description: "", name: "Add Dev Extension",
// function: async () => { iconifyIcon: "lineicons:dev",
// const appStateStore = useAppStateStore() description: "",
// appStateStore.setSearchTermSync("") function: async () => {
// goto("/add-dev-ext") appState.clearSearchTerm()
// } goto("/settings/add-dev-extension")
// }, }
// { },
// name: "Kunkun Version", {
// iconifyIcon: "stash:version-solid", name: "Kunkun Version",
// description: "", iconifyIcon: "stash:version-solid",
// function: async () => { description: "",
// toast.success(`Kunkun Version: ${await getVersion()}`) function: async () => {
// } toast.success(`Kunkun Version: ${await getVersion()}`)
// }, }
},
{ {
name: "Set Dev Extension Path", name: "Set Dev Extension Path",
iconifyIcon: "lineicons:dev", iconifyIcon: "lineicons:dev",
@ -75,52 +81,48 @@ export const builtinCmds: BuiltinCmd[] = [
goto("/settings/set-dev-ext-path") goto("/settings/set-dev-ext-path")
} }
}, },
// { {
// name: "Extension Window Troubleshooter", name: "Extension Window Troubleshooter",
// iconifyIcon: "material-symbols:window-outline", iconifyIcon: "material-symbols:window-outline",
// description: "", description: "",
// function: async () => { function: async () => {
// const appStateStore = useAppStateStore() appState.clearSearchTerm()
// appStateStore.setSearchTermSync("") // goto("/window-troubleshooter")
// // goto("/window-troubleshooter") const winLabel = `main:extension-window-troubleshooter-${uuidv4()}`
// const winLabel = `main:window-troubleshooter-${uuidv4()}` console.log(winLabel)
// console.log(winLabel) new WebviewWindow(winLabel, {
// new WebviewWindow(winLabel, { url: "/troubleshooters/extension-window",
// url: "/window-troubleshooter", title: "Extension Window Troubleshooter"
// title: "Window Troubleshooter" })
// }) }
// } },
// }, {
// { name: "Extension Permission Inspector",
// name: "Extension Permission Inspector", iconifyIcon: "hugeicons:inspect-code",
// iconifyIcon: "hugeicons:inspect-code", description: "",
// description: "", function: async () => {
// function: async () => { appState.clearSearchTerm()
// const appStateStore = useAppStateStore() goto("/extension/permission-inspector")
// appStateStore.setSearchTermSync("") }
// goto("/ext-permission-inspector") },
// } {
// }, name: "Extension Loading Troubleshooter",
// { iconifyIcon: "material-symbols:troubleshoot",
// name: "Extension Loading Troubleshooter", description: "",
// iconifyIcon: "material-symbols:troubleshoot", function: async () => {
// description: "", appState.clearSearchTerm()
// function: async () => { goto("/troubleshooters/extension-loading")
// const appStateStore = useAppStateStore() }
// appStateStore.setSearchTermSync("") },
// goto("/extension-load-troubleshooter") {
// } name: "Create Quicklink",
// }, iconifyIcon: "material-symbols:link",
// { description: "Create a Quicklink",
// name: "Create Quicklink", function: async () => {
// iconifyIcon: "material-symbols:link", appState.clearSearchTerm()
// description: "Create a Quicklink", goto("/extension/create-quick-link")
// function: async () => { }
// const appStateStore = useAppStateStore() },
// appStateStore.setSearchTermSync("")
// goto("/create-quicklink")
// }
// },
// { // {
// name: "Settings", // name: "Settings",
// iconifyIcon: "solar:settings-linear", // iconifyIcon: "solar:settings-linear",
@ -143,30 +145,32 @@ export const builtinCmds: BuiltinCmd[] = [
// appStateStore.setSearchTermSync("") // appStateStore.setSearchTermSync("")
// } // }
// }, // },
// { {
// name: "Check Update", name: "Check Update",
// iconifyIcon: "material-symbols:update", iconifyIcon: "material-symbols:update",
// description: "Check for updates", description: "Check for updates",
// function: async () => { function: async () => {
// checkUpdateAndInstall() checkUpdateAndInstall()
// } appState.clearSearchTerm()
// }, }
// { },
// name: "Check Beta Update", {
// iconifyIcon: "material-symbols:update", name: "Check Beta Update",
// description: "Check for Beta updates", iconifyIcon: "material-symbols:update",
// function: async () => { description: "Check for Beta updates",
// checkUpdateAndInstall(true) function: async () => {
// } checkUpdateAndInstall({ beta: true })
// }, appState.clearSearchTerm()
// { }
// name: "Reload", },
// iconifyIcon: "tabler:reload", {
// description: "Reload this page", name: "Reload",
// function: async () => { iconifyIcon: "tabler:reload",
// location.reload() description: "Reload this page",
// } function: async () => {
// }, location.reload()
}
},
{ {
name: "Dance", name: "Dance",
iconifyIcon: "mdi:dance-pole", iconifyIcon: "mdi:dance-pole",
@ -174,33 +178,43 @@ export const builtinCmds: BuiltinCmd[] = [
function: async () => { function: async () => {
goto("/dance") 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 { winExtMap } from "@/stores/winExtMap"
import { trimSlash } from "@/utils/url" import { trimSlash } from "@/utils/url"
import { constructExtensionSupportDir } from "@kksh/api" 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 { launchNewExtWindow } from "@kksh/extension"
import { convertFileSrc } from "@tauri-apps/api/core" import { convertFileSrc } from "@tauri-apps/api/core"
import { WebviewWindow } from "@tauri-apps/api/webviewWindow"
import * as fs from "@tauri-apps/plugin-fs" import * as fs from "@tauri-apps/plugin-fs"
import { debug } from "@tauri-apps/plugin-log"
import { goto } from "$app/navigation" import { goto } from "$app/navigation"
import * as v from "valibot"
export async function createExtSupportDir(extPath: string) { export async function createExtSupportDir(extPath: string) {
const extSupportDir = await constructExtensionSupportDir(extPath) const extSupportDir = await constructExtensionSupportDir(extPath)
@ -23,7 +21,19 @@ export async function onTemplateUiCmdSelect(
{ isDev, hmr }: { isDev: boolean; hmr: boolean } { isDev, hmr }: { isDev: boolean; hmr: boolean }
) { ) {
await createExtSupportDir(ext.extPath) 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( export async function onCustomUiCmdSelect(
@ -31,7 +41,7 @@ export async function onCustomUiCmdSelect(
cmd: CustomUiCmd, cmd: CustomUiCmd,
{ isDev, hmr }: { isDev: boolean; hmr: boolean } { isDev, hmr }: { isDev: boolean; hmr: boolean }
) { ) {
console.log("onCustomUiCmdSelect", ext, cmd, isDev, hmr) // console.log("onCustomUiCmdSelect", ext, cmd, isDev, hmr)
await createExtSupportDir(ext.extPath) await createExtSupportDir(ext.extPath)
let url = cmd.main let url = cmd.main
@ -48,14 +58,14 @@ export async function onCustomUiCmdSelect(
}) })
console.log("Launch new window, ", winLabel) console.log("Launch new window, ", winLabel)
const window = launchNewExtWindow(winLabel, url2, cmd.window) const window = launchNewExtWindow(winLabel, url2, cmd.window)
window.onCloseRequested(async (event) => {
await winExtMap.unregisterExtensionFromWindow(winLabel)
})
} else { } else {
console.log("Launch main window") console.log("Launch main window")
return winExtMap return winExtMap
.registerExtensionWithWindow({ .registerExtensionWithWindow({ windowLabel: "main", extPath: ext.extPath, dist: cmd.dist })
windowLabel: "main",
extPath: ext.extPath,
dist: cmd.dist
})
.then(() => goto(url2)) .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 type { CommandLaunchers, OnExtCmdSelect } from "@kksh/ui/types"
import * as v from "valibot" import * as v from "valibot"
import { onCustomUiCmdSelect, onTemplateUiCmdSelect } from "./ext" import { onCustomUiCmdSelect, onTemplateUiCmdSelect } from "./ext"
import { onQuickLinkSelect } from "./quick-links"
const onExtCmdSelect: OnExtCmdSelect = ( const onExtCmdSelect: OnExtCmdSelect = (
ext: ExtPackageJsonExtra, 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 { 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 { 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 { debug, error } from "@tauri-apps/plugin-log"
import * as os from "@tauri-apps/plugin-os" import * as os from "@tauri-apps/plugin-os"
import { load } from "@tauri-apps/plugin-store" import { load } from "@tauri-apps/plugin-store"
import { get, writable, type Writable } from "svelte/store"
import * as v from "valibot" import * as v from "valibot"
export const defaultAppConfig: AppConfig = { export const defaultAppConfig: AppConfig = {
@ -21,7 +19,7 @@ export const defaultAppConfig: AppConfig = {
launchAtLogin: true, launchAtLogin: true,
showInTray: true, showInTray: true,
devExtensionPath: null, devExtensionPath: null,
extensionPath: undefined, extensionsInstallDir: undefined,
hmr: false, hmr: false,
hideOnBlur: true, hideOnBlur: true,
extensionAutoUpgrade: true, extensionAutoUpgrade: true,
@ -35,25 +33,22 @@ interface AppConfigAPI {
setDevExtensionPath: (devExtensionPath: string | null) => void setDevExtensionPath: (devExtensionPath: string | null) => void
} }
function createAppConfig(): Writable<AppConfig> & AppConfigAPI { function createAppConfig(): WithSyncStore<AppConfig> & AppConfigAPI {
const { subscribe, update, set } = writable<AppConfig>(defaultAppConfig) const store = createTauriSyncStore("app-config", defaultAppConfig)
async function init() { async function init() {
debug("Initializing app config") 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 persistStore = await load("kk-config.json", { autoSave: true })
const loadedConfig = await persistStore.get("config") const loadedConfig = await persistStore.get("config")
const parseRes = v.safeParse(PersistedAppConfig, loadedConfig) const parseRes = v.safeParse(PersistedAppConfig, loadedConfig)
if (parseRes.success) { if (parseRes.success) {
console.log("Parse Persisted App Config Success", parseRes.output) console.log("Parse Persisted App Config Success", parseRes.output)
const extensionPath = await path.join(appDataDir, "extensions") const extensionsInstallDir = await getExtensionsFolder()
update((config) => ({ store.update((config) => ({
...config, ...config,
...parseRes.output, ...parseRes.output,
isInitialized: true, isInitialized: true,
extensionPath, extensionsInstallDir,
platform: os.platform() platform: os.platform()
})) }))
} else { } else {
@ -63,7 +58,7 @@ function createAppConfig(): Writable<AppConfig> & AppConfigAPI {
await persistStore.set("config", v.parse(PersistedAppConfig, defaultAppConfig)) await persistStore.set("config", v.parse(PersistedAppConfig, defaultAppConfig))
} }
subscribe(async (config) => { store.subscribe(async (config) => {
console.log("Saving app config", config) console.log("Saving app config", config)
await persistStore.set("config", config) await persistStore.set("config", config)
updateTheme(config.theme) updateTheme(config.theme)
@ -71,15 +66,13 @@ function createAppConfig(): Writable<AppConfig> & AppConfigAPI {
} }
return { return {
setTheme: (theme: ThemeConfig) => update((config) => ({ ...config, theme })), ...store,
setTheme: (theme: ThemeConfig) => store.update((config) => ({ ...config, theme })),
setDevExtensionPath: (devExtensionPath: string | null) => { setDevExtensionPath: (devExtensionPath: string | null) => {
console.log("setDevExtensionPath", devExtensionPath) console.log("setDevExtensionPath", devExtensionPath)
update((config) => ({ ...config, devExtensionPath })) store.update((config) => ({ ...config, devExtensionPath }))
}, },
init, init
subscribe,
update,
set
} }
} }

View File

@ -1,26 +1,42 @@
import type { AppState } from "@/types" import { findAllArgsInLink } from "@/cmds/quick-links"
import { get, writable, type Writable } from "svelte/store" 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 = { export const defaultAppState: AppState = {
searchTerm: "", searchTerm: "",
highlightedCmd: "" highlightedCmd: "",
loadingBar: false,
defaultAction: "",
actionPanel: undefined
} }
interface AppStateAPI { interface AppStateAPI {
clearSearchTerm: () => void clearSearchTerm: () => void
get: () => AppState get: () => AppState
setLoadingBar: (loadingBar: boolean) => void
setDefaultAction: (defaultAction: string) => void
setActionPanel: (actionPanel?: ActionSchema.ActionPanel) => void
} }
function createAppState(): Writable<AppState> & AppStateAPI { function createAppState(): Writable<AppState> & AppStateAPI {
const store = writable<AppState>(defaultAppState) const store = writable<AppState>(defaultAppState)
return { return {
subscribe: store.subscribe, ...store,
update: store.update,
set: store.set,
get: () => get(store), get: () => get(store),
clearSearchTerm: () => { clearSearchTerm: () => {
store.update((state) => ({ ...state, searchTerm: "" })) 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[]> & { function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
init: () => Promise<void> init: () => Promise<void>
getExtensionsFromStore: () => ExtPackageJsonExtra[] getExtensionsFromStore: () => ExtPackageJsonExtra[]
installTarball: (tarballPath: string, extsDir: string) => Promise<ExtPackageJsonExtra>
installDevExtensionDir: (dirPath: string) => Promise<ExtPackageJsonExtra>
installFromTarballUrl: (tarballUrl: string, installDir: string) => Promise<ExtPackageJsonExtra> installFromTarballUrl: (tarballUrl: string, installDir: string) => Promise<ExtPackageJsonExtra>
installFromNpmPackageName: (name: string, installDir: string) => Promise<ExtPackageJsonExtra>
findStoreExtensionByIdentifier: (identifier: string) => ExtPackageJsonExtra | undefined findStoreExtensionByIdentifier: (identifier: string) => ExtPackageJsonExtra | undefined
registerNewExtensionByPath: (extPath: string) => Promise<ExtPackageJsonExtra> registerNewExtensionByPath: (extPath: string) => Promise<ExtPackageJsonExtra>
uninstallStoreExtensionByIdentifier: (identifier: string) => Promise<ExtPackageJsonExtra> uninstallStoreExtensionByIdentifier: (identifier: string) => Promise<ExtPackageJsonExtra>
upgradeStoreExtension: (identifier: string, tarballUrl: string) => Promise<ExtPackageJsonExtra> upgradeStoreExtension: (identifier: string, tarballUrl: string) => Promise<ExtPackageJsonExtra>
} { } {
const { subscribe, update, set } = writable<ExtPackageJsonExtra[]>([]) const store = writable<ExtPackageJsonExtra[]>([])
function init() { function init() {
return extAPI.loadAllExtensionsFromDb().then((exts) => { return extAPI.loadAllExtensionsFromDb().then((exts) => {
set(exts) store.set(exts)
}) })
} }
function getExtensionsFromStore() { function getExtensionsFromStore() {
const extContainerPath = get(appConfig).extensionPath const extContainerPath = get(appConfig).extensionsInstallDir
if (!extContainerPath) return [] if (!extContainerPath) return []
return get(extensions).filter((ext) => !extAPI.isExtPathInDev(extContainerPath, ext.extPath)) return get(extensions).filter((ext) => !extAPI.isExtPathInDev(extContainerPath, ext.extPath))
} }
@ -43,7 +46,7 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
return extAPI return extAPI
.loadExtensionManifestFromDisk(await path.join(extPath, "package.json")) .loadExtensionManifestFromDisk(await path.join(extPath, "package.json"))
.then((ext) => { .then((ext) => {
update((exts) => { store.update((exts) => {
const existingExt = exts.find((e) => e.extPath === ext.extPath) const existingExt = exts.find((e) => e.extPath === ext.extPath)
if (existingExt) return exts if (existingExt) return exts
return [...exts, ext] 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) { async function installFromTarballUrl(tarballUrl: string, extsDir: string) {
return extAPI.installTarballUrl(tarballUrl, extsDir).then((extInstallPath) => { return extAPI.installTarballUrl(tarballUrl, extsDir).then((extInstallPath) => {
return registerNewExtensionByPath(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) { async function uninstallExtensionByPath(targetPath: string) {
const targetExt = get(extensions).find((ext) => ext.extPath === targetPath) const targetExt = get(extensions).find((ext) => ext.extPath === targetPath)
if (!targetExt) throw new Error(`Extension ${targetPath} not registered in DB`) if (!targetExt) throw new Error(`Extension ${targetPath} not registered in DB`)
@ -69,7 +96,7 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
return extAPI return extAPI
.uninstallExtensionByPath(targetPath) .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) .then(() => targetExt)
} }
@ -83,7 +110,7 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
identifier: string, identifier: string,
tarballUrl: string tarballUrl: string
): Promise<ExtPackageJsonExtra> { ): Promise<ExtPackageJsonExtra> {
const extsDir = get(appConfig).extensionPath const extsDir = get(appConfig).extensionsInstallDir
if (!extsDir) throw new Error("Extension path not set") if (!extsDir) throw new Error("Extension path not set")
return uninstallStoreExtensionByIdentifier(identifier).then(() => return uninstallStoreExtensionByIdentifier(identifier).then(() =>
installFromTarballUrl(tarballUrl, extsDir) installFromTarballUrl(tarballUrl, extsDir)
@ -91,16 +118,17 @@ function createExtensionsStore(): Writable<ExtPackageJsonExtra[]> & {
} }
return { return {
...store,
init, init,
getExtensionsFromStore, getExtensionsFromStore,
findStoreExtensionByIdentifier, findStoreExtensionByIdentifier,
registerNewExtensionByPath, registerNewExtensionByPath,
installTarball,
installDevExtensionDir,
installFromTarballUrl, installFromTarballUrl,
installFromNpmPackageName,
uninstallStoreExtensionByIdentifier, uninstallStoreExtensionByIdentifier,
upgradeStoreExtension, upgradeStoreExtension
subscribe,
update,
set
} }
} }
@ -109,7 +137,7 @@ export const extensions = createExtensionsStore()
export const installedStoreExts: Readable<ExtPackageJsonExtra[]> = derived( export const installedStoreExts: Readable<ExtPackageJsonExtra[]> = derived(
extensions, extensions,
($extensionsStore) => { ($extensionsStore) => {
const extContainerPath = get(appConfig).extensionPath const extContainerPath = get(appConfig).extensionsInstallDir
if (!extContainerPath) return [] if (!extContainerPath) return []
return $extensionsStore.filter((ext) => !extAPI.isExtPathInDev(extContainerPath, ext.extPath)) 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( export const devStoreExts: Readable<ExtPackageJsonExtra[]> = derived(
extensions, extensions,
($extensionsStore) => { ($extensionsStore) => {
const extContainerPath = get(appConfig).extensionPath const extContainerPath = get(appConfig).extensionsInstallDir
if (!extContainerPath) return [] if (!extContainerPath) return []
return $extensionsStore.filter((ext) => extAPI.isExtPathInDev(extContainerPath, ext.extPath)) return $extensionsStore.filter((ext) => extAPI.isExtPathInDev(extContainerPath, ext.extPath))
} }

View File

@ -2,3 +2,5 @@ export * from "./appConfig"
export * from "./appState" export * from "./appState"
export * from "./winExtMap" export * from "./winExtMap"
export * from "./extensions" 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() {} async function init() {}
return { return {
...store,
init, init,
registerExtensionWithWindow: async ({ registerExtensionWithWindow: async ({
extPath, extPath,
@ -58,11 +59,11 @@ function createWinExtMapStore(): Writable<WinExtMap> & API {
await killProcesses(winExtMap[windowLabel].pids) await killProcesses(winExtMap[windowLabel].pids)
delete winExtMap[windowLabel] delete winExtMap[windowLabel]
} else { } else {
winExtMap[windowLabel] = { // winExtMap[windowLabel] = {
windowLabel, // windowLabel,
extPath, // extPath,
pids: [] // pids: []
} // }
} }
} }
const returnedWinLabel = await registerExtensionWindow({ const returnedWinLabel = await registerExtensionWindow({
@ -70,6 +71,11 @@ function createWinExtMapStore(): Writable<WinExtMap> & API {
windowLabel, windowLabel,
dist dist
}) })
winExtMap[returnedWinLabel] = {
windowLabel: returnedWinLabel,
extPath,
pids: []
}
store.set(winExtMap) store.set(winExtMap)
return returnedWinLabel return returnedWinLabel
}, },
@ -109,10 +115,7 @@ function createWinExtMapStore(): Writable<WinExtMap> & API {
return unregisterExtensionSpawnedProcess(windowLabel, pid).then(() => { return unregisterExtensionSpawnedProcess(windowLabel, pid).then(() => {
ext.pids = ext.pids.filter((p) => p !== pid) 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"> <script lang="ts">
import { goHome } from "@/utils/route"
import { Error, Layouts } from "@kksh/ui" import { Error, Layouts } from "@kksh/ui"
import { page } from "$app/stores" import { page } from "$app/stores"
@ -11,12 +12,12 @@
<svelte:window on:keydown={handleKeyDown} /> <svelte:window on:keydown={handleKeyDown} />
<Layouts.Center class="h-screen"> <Layouts.Center class="min-h-screen py-5">
<Error.RawErrorJSONPreset <Error.RawErrorJSONPreset
title="Unknown Error" title="Error"
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"} message={$page.error?.message ?? "Unknown Error"}
onnGoBack={() => window.history.back()} onGoBack={goHome}
rawJsonError={JSON.stringify($page, null, 2)} rawJsonError={JSON.stringify($page, null, 2)}
/> />
</Layouts.Center> </Layouts.Center>

View File

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import AppContext from "@/components/context/AppContext.svelte" import AppContext from "@/components/context/AppContext.svelte"
import "../app.css" 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 { isInMainWindow } from "@/utils/window"
import { import {
ModeWatcher, ModeWatcher,
@ -16,11 +17,19 @@
import { attachConsole } from "@tauri-apps/plugin-log" import { attachConsole } from "@tauri-apps/plugin-log"
import { onDestroy, onMount } from "svelte" import { onDestroy, onMount } from "svelte"
onMount(() => {
setTimeout(() => {
import("virtual:uno.css")
}, 1000)
})
let { children } = $props() let { children } = $props()
const unlisteners: UnlistenFn[] = [] const unlisteners: UnlistenFn[] = []
onMount(async () => { onMount(async () => {
unlisteners.push(await attachConsole()) attachConsole().then((unlistener) => unlisteners.push(unlistener))
initDeeplink().then((unlistener) => unlisteners.push(unlistener))
quickLinks.init()
appConfig.init() appConfig.init()
if (isInMainWindow()) { if (isInMainWindow()) {
extensions.init() 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 // Tauri doesn't have a Node.js server to do proper SSR
// so we will use adapter-static to prerender the app (SSG) // so we will use adapter-static to prerender the app (SSG)
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info // See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const prerender = true export const prerender = true
export const ssr = false 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"> <script lang="ts">
import { commandLaunchers } from "@/cmds" import { commandLaunchers } from "@/cmds"
import { builtinCmds } from "@/cmds/builtin" import { builtinCmds } from "@/cmds/builtin"
import CommandPalette from "@/components/main/CommandPalette.svelte" import { systemCommands } from "@/cmds/system"
import { appState } from "@/stores" import { appConfig, appState, devStoreExts, installedStoreExts, quickLinks } from "@/stores"
import { appConfig } from "@/stores/appConfig" import { cmdQueries } from "@/stores/cmdQuery"
import { extensions } from "@/stores/extensions" import { getActiveElementNodeName } from "@/utils/dom"
import "@kksh/ui" 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> </script>
<CommandPalette <svelte:window on:keydown={onKeyDown} />
class="h-screen" <Command.Root
extensions={$extensions} class={cn("h-screen rounded-lg border shadow-md")}
{appState} bind:value={$appState.highlightedCmd}
{appConfig} filter={(value, search, keywords) => {
{commandLaunchers} return commandScore(
{builtinCmds} 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 { getExtensionsFolder } from "@/constants"
import { appState, extensions } from "@/stores" import { appState, extensions } from "@/stores"
import { supabaseAPI } from "@/supabase" import { supabaseAPI } from "@/supabase"
import { goBackOnEscapeClearSearchTerm } from "@/utils/key" import { goBackOnEscapeClearSearchTerm, goHomeOnEscapeClearSearchTerm } from "@/utils/key"
import { goBack } from "@/utils/route" import { goBack, goHome } from "@/utils/route"
import { SBExt } from "@kksh/api/supabase" import { SBExt } from "@kksh/api/supabase"
import { isUpgradable } from "@kksh/extension" import { isUpgradable } from "@kksh/extension"
import { Button, Command } from "@kksh/svelte5" import { Button, Command } from "@kksh/svelte5"
@ -64,20 +64,19 @@
} }
</script> </script>
<svelte:window on:keydown={goBackOnEscapeClearSearchTerm} /> <svelte:window on:keydown={goHomeOnEscapeClearSearchTerm} />
{#snippet leftSlot()} {#snippet leftSlot()}
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onclick={goBack} onclick={goHome}
class={Constants.CLASSNAMES.BACK_BUTTON} class={Constants.CLASSNAMES.BACK_BUTTON}
data-flip-id={Constants.CLASSNAMES.BACK_BUTTON} data-flip-id={Constants.CLASSNAMES.BACK_BUTTON}
> >
<ArrowLeft class="size-4" /> <ArrowLeft class="size-4" />
</Button> </Button>
{/snippet} {/snippet}
<Command.Root class="h-screen rounded-lg border shadow-md"> <Command.Root class="h-screen rounded-lg border shadow-md" loop>
<CustomCommandInput <CustomCommandInput
autofocus autofocus
placeholder="Type a command or search..." placeholder="Type a command or search..."

View File

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

View File

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

View File

@ -13,7 +13,12 @@ export const load: PageLoad = async ({
}): Promise<{ }): Promise<{
ext: Tables<"ext_publish"> ext: Tables<"ext_publish">
manifest: KunkunExtManifest manifest: KunkunExtManifest
params: {
identifier: string
}
}> => { }> => {
console.log("store[identifier] params", params)
const { error: dbError, data: ext } = await supabaseAPI.getLatestExtPublish(params.identifier) const { error: dbError, data: ext } = await supabaseAPI.getLatestExtPublish(params.identifier)
if (dbError) { if (dbError) {
return error(400, { return error(400, {
@ -30,6 +35,7 @@ export const load: PageLoad = async ({
return { return {
ext, ext,
params,
manifest: parseManifest.output 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" import { ArrowLeftIcon } from "lucide-svelte"
</script> </script>
<svelte:window on:keydown|preventDefault={goBackOnEscape} /> <svelte:window on:keydown={goBackOnEscape} />
<Button variant="outline" size="icon" class="absolute left-2 top-2 z-50" onclick={goBack}> <Button variant="outline" size="icon" class="fixed left-2 top-2 z-50" onclick={goBack}>
<ArrowLeftIcon class="h-4 w-4" /> <ArrowLeftIcon class="h-4 w-4" />
</Button> </Button>
<div class="absolute left-0 top-0 h-10 w-screen" data-tauri-drag-region></div> <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 type { Config } from "tailwindcss"
import tailwindcssAnimate from "tailwindcss-animate" import tailwindcssAnimate from "tailwindcss-animate"
import { fontFamily } from "tailwindcss/defaultTheme" import { fontFamily } from "tailwindcss/defaultTheme"
@ -9,7 +10,7 @@ const config: Config = {
"./node_modules/@kksh/ui/src/**/*.{html,js,svelte,ts}", "./node_modules/@kksh/ui/src/**/*.{html,js,svelte,ts}",
"../../node_modules/@kksh/svelte5/src/**/*.{html,js,svelte,ts}" "../../node_modules/@kksh/svelte5/src/**/*.{html,js,svelte,ts}"
], ],
safelist: ["dark"], safelist: ["dark", "bg-red-500/30"],
theme: { theme: {
container: { container: {
center: true, center: true,
@ -94,7 +95,7 @@ const config: Config = {
} }
} }
}, },
plugins: [tailwindcssAnimate] plugins: [tailwindcssAnimate, typography]
} }
export default config export default config

View File

@ -1,19 +1,20 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler"
} },
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // 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 // 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 // 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 // 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 { sveltekit } from "@sveltejs/kit/vite"
import UnoCSS from "unocss/vite"
import { defineConfig } from "vite" import { defineConfig } from "vite"
// @ts-expect-error process is a nodejs global // @ts-expect-error process is a nodejs global
@ -6,8 +7,7 @@ const host = process.env.TAURI_DEV_HOST
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [sveltekit()], plugins: [UnoCSS(), sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
// 1. prevent vite from obscuring rust errors // 1. prevent vite from obscuring rust errors

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -41,6 +41,8 @@ export type Ext = InferOutput<typeof Ext>
export enum CmdTypeEnum { export enum CmdTypeEnum {
HeadlessWorker = "headless_worker", HeadlessWorker = "headless_worker",
Builtin = "builtin",
System = "system",
UiWorker = "ui_worker", UiWorker = "ui_worker",
UiIframe = "ui_iframe", UiIframe = "ui_iframe",
QuickLink = "quick_link", QuickLink = "quick_link",
@ -55,12 +57,18 @@ export const ExtCmd = object({
name: string(), name: string(),
type: CmdType, type: CmdType,
data: string(), data: string(),
alias: optional(string()), alias: nullable(optional(string())),
hotkey: optional(string()), hotkey: nullable(optional(string())),
enabled: boolean() enabled: boolean()
}) })
export type ExtCmd = InferOutput<typeof ExtCmd> 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({ export const ExtData = object({
dataId: number(), dataId: number(),
extId: 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" import { NodeName, NodeNameEnum } from "./constants"
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -16,7 +25,8 @@ export type IconType = InferOutput<typeof IconType>
export const Icon = object({ export const Icon = object({
type: IconType, type: IconType,
value: string() value: string(),
invert: optional(boolean())
}) })
export type Icon = InferOutput<typeof Icon> export type Icon = InferOutput<typeof Icon>
export const IconNode = object({ export const IconNode = object({

View File

@ -33,6 +33,7 @@ import type { fileSearch } from "../commands/fileSearch"
import { type AppInfo } from "../models/apps" import { type AppInfo } from "../models/apps"
import type { LightMode, Position, Radius, ThemeColor } from "../models/styles" import type { LightMode, Position, Radius, ThemeColor } from "../models/styles"
import type { DenoSysOptions } from "../permissions/schema" import type { DenoSysOptions } from "../permissions/schema"
import type { MarkdownSchema } from "./worker"
import { type IComponent } from "./worker/components/interfaces" import { type IComponent } from "./worker/components/interfaces"
import type { Markdown } from "./worker/components/markdown" import type { Markdown } from "./worker/components/markdown"
import * as FormSchema from "./worker/schema/form" import * as FormSchema from "./worker/schema/form"
@ -116,7 +117,7 @@ export interface IToast {
} }
export interface IUiWorker { 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> goBack: () => Promise<void>
showLoadingBar: (loading: boolean) => Promise<void> showLoadingBar: (loading: boolean) => Promise<void>
setScrollLoading: (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)[] }>) { constructor(model: OmitNodeName<FormSchema.Form & { fields: (AllFormFields | Form)[] }>) {
this.fields = model.fields this.fields = model.fields
this.key = model.key this.key = model.key
this.title = model.title
this.description = model.description
this.submitBtnText = model.submitBtnText
} }
toModel(): FormSchema.Form { toModel(): FormSchema.Form {

View File

@ -65,7 +65,8 @@ export type BaseField = InferOutput<typeof BaseField>
export const InputField = object({ export const InputField = object({
...BaseField.entries, ...BaseField.entries,
type: optional(InputTypes), 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> export type InputField = InferOutput<typeof InputField>
@ -74,7 +75,8 @@ export type InputField = InferOutput<typeof InputField>
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
export const NumberField = object({ export const NumberField = object({
...BaseField.entries, ...BaseField.entries,
nodeName: FormNodeName nodeName: FormNodeName,
default: optional(number())
}) })
export type NumberField = InferOutput<typeof NumberField> export type NumberField = InferOutput<typeof NumberField>
@ -84,7 +86,8 @@ export type NumberField = InferOutput<typeof NumberField>
// with zod enum // with zod enum
export const SelectField = object({ export const SelectField = object({
...BaseField.entries, ...BaseField.entries,
options: array(string()) options: array(string()),
default: optional(string())
}) })
export type SelectField = InferOutput<typeof SelectField> export type SelectField = InferOutput<typeof SelectField>
@ -101,7 +104,8 @@ export type BooleanField = InferOutput<typeof BooleanField>
/* Date */ /* Date */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
export const DateField = object({ export const DateField = object({
...BaseField.entries ...BaseField.entries,
default: optional(string())
}) })
export type DateField = InferOutput<typeof DateField> export type DateField = InferOutput<typeof DateField>
@ -121,14 +125,22 @@ export type ArrayField = InferOutput<typeof ArrayField>
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
export const FormField = union([ export const FormField = union([
ArrayField, // this must be placed first, otherwise its content field won't be parsed ArrayField, // this must be placed first, otherwise its content field won't be parsed
SelectField,
InputField, InputField,
NumberField, NumberField,
SelectField,
BooleanField, BooleanField,
DateField DateField
]) ])
export type FormField = InferOutput<typeof FormField> export type FormField = InferOutput<typeof FormField>
// export type Form = InferOutput<typeof Form> // 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 = { export type Form = {
nodeName: FormNodeName nodeName: FormNodeName
title?: string title?: string
@ -137,8 +149,3 @@ export type Form = {
key: string key: string
fields: (FormField | Form)[] 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 checkpointVersions = breakingChangesVersionCheckpoints.map((c) => c.version)
const sortedCheckpointVersions = sort(checkpointVersions) 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) { export function isVersionBetween(v: string, start: string, end: string) {
const vCleaned = clean(v) const vCleaned = clean(v)

View File

@ -1,5 +1,13 @@
import { db } from "@kksh/api/commands" 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) { export async function upsertExtension(extPkgJson: ExtPackageJson, extFullPath: string) {
const extInDb = await db.getUniqueExtensionByIdentifier(extPkgJson.kunkun.identifier) 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> { export async function installDevExtensionDir(extPath: string): Promise<ExtPackageJsonExtra> {
const manifestPath = await path.join(extPath, "package.json") const manifestPath = await path.join(extPath, "package.json")
if (!(await fs.exists(manifestPath))) { 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": { "devDependencies": {
"@gcornut/valibot-json-schema": "^0.42.0", "@gcornut/valibot-json-schema": "^0.42.0",
"@types/bun": "latest", "@types/bun": "latest"
"supabase": ">=1.8.1"
}, },
"peerDependencies": { "peerDependencies": {
"@kksh/supabase": "workspace:*", "@kksh/supabase": "workspace:*",
@ -25,7 +24,6 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.583.0", "@aws-sdk/client-s3": "^3.583.0",
"@kksh/api": "workspace:*", "@kksh/api": "workspace:*",
"@supabase/supabase-js": "^2.43.4",
"valibot": "^0.40.0" "valibot": "^0.40.0"
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,24 +34,31 @@
"lint": "eslint ." "lint": "eslint ."
}, },
"devDependencies": { "devDependencies": {
"@iconify/svelte": "^4.0.2",
"@kksh/api": "workspace:*", "@kksh/api": "workspace:*",
"@kksh/svelte5": "^0.1.2-beta.8",
"@types/bun": "latest", "@types/bun": "latest",
"bits-ui": "1.0.0-next.36", "bits-ui": "1.0.0-next.45",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"formsnap": "2.0.0-next.1",
"lucide-svelte": "^0.454.0", "lucide-svelte": "^0.454.0",
"mode-watcher": "^0.4.1", "mode-watcher": "^0.4.1",
"paneforge": "1.0.0-next.1", "paneforge": "1.0.0-next.1",
"shiki": "^1.22.2", "shiki": "^1.22.2",
"svelte-radix": "^2.0.1", "svelte-radix": "^2.0.1",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.20.0",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"tailwind-variants": "^0.2.1", "tailwind-variants": "^0.2.1",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
}, },
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^0.8.2", "@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.6",
"@std/semver": "npm:@jsr/std__semver@^1.0.3", "@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> </script>
{#if icon.type === IconEnum.RemoteUrl} {#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} {: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} {: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} {: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} {icon.value}
</Button> </Button>
{:else if icon.type === IconEnum.Svg} {: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} {:else}
<Icon icon="mingcute:appstore-fill" class={cn("", className)} {...restProps} /> <Icon
icon="mingcute:appstore-fill"
class={cn("", className, { invert: icon.invert })}
{...restProps}
/>
{/if} {/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