feat: early raycast support

This commit is contained in:
samfundev 2025-03-20 00:52:02 -04:00
parent 3542eec277
commit 2a4400dff3
No known key found for this signature in database
GPG key ID: 90D590E2A224B85D
19 changed files with 2403 additions and 682 deletions

View file

@ -19,6 +19,7 @@
"@inlang/paraglide-sveltekit": "0.16.0",
"@kksh/drizzle": "workspace:*",
"@kksh/extension": "workspace:*",
"@kksh/raycast": "workspace:*",
"@kksh/svelte5": "^0.1.15",
"@kksh/ui": "workspace:*",
"@kksh/utils": "workspace:*",
@ -30,6 +31,7 @@
"@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-sql": "^2.2.0",
"@tauri-apps/plugin-stronghold": "^2.2.0",
"@tauri-apps/plugin-websocket": "^2.3.0",
"@tauri-store/svelte": "^2.1.1",
"dompurify": "^3.2.4",
"drizzle-orm": "^0.41.0",

View file

@ -59,6 +59,7 @@ obfstr = { workspace = true }
base64 = { workspace = true }
tauri-plugin-stronghold = "2.2.0"
tauri-plugin-sql = "2"
tauri-plugin-websocket = "2"
[target."cfg(target_os = \"macos\")".dependencies]

View file

@ -130,6 +130,7 @@
"system-info:allow-all",
"user-input:default",
"shell:default",
"shell:allow-stdin-write",
"keyring:default",
"stronghold:default",
{
@ -143,12 +144,22 @@
"validator": ".+"
}
]
},
{
"name": "node",
"cmd": "node",
"args": [
{
"validator": ".+"
}
]
}
]
},
"deep-link:default",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled"
"autostart:allow-is-enabled",
"websocket:default"
]
}

View file

@ -136,6 +136,7 @@ pub fn run() {
.plugin(tauri_plugin_keyring::init())
.plugin(tauri_plugin_network::init())
.plugin(tauri_plugin_system_info::init())
.plugin(tauri_plugin_websocket::init())
.invoke_handler(tauri::generate_handler![
commands::keyring::get_stronghold_key,
]);

View file

@ -207,3 +207,62 @@ export async function onCustomUiCmdSelect(
}
appState.clearSearchTerm()
}
export async function onRaycastCmdSelect(
ext: ExtPackageJsonExtra,
cmd: CustomUiCmd,
{ isDev, hmr }: { isDev: boolean; hmr: boolean }
) {
// console.log("onCustomUiCmdSelect", ext, cmd, isDev, hmr)
await createExtSupportDir(ext.extPath)
let url = cmd.main
const useDevMain = hmr && isDev && cmd.devMain
if (useDevMain) {
url = cmd.devMain
} else {
url = cmd.main.startsWith("http")
? cmd.main
: decodeURIComponent(convertFileSrc(`${trimSlash(cmd.main)}`, "ext"))
}
let url2 = `/app/extension/raycast?url=${encodeURIComponent(url)}&extPath=${encodeURIComponent(ext.extPath)}`
// url2 = `/dev?url=${encodeURIComponent(url)}&extPath=${encodeURIComponent(ext.extPath)}`
setIframeExtParams(ext.extPath, url)
if (cmd.window) {
const winLabel = await winExtMap.registerExtensionWithWindow({
extPath: ext.extPath,
dist: cmd.dist
})
if (platform() === "windows" && !useDevMain) {
const addr = await spawnExtensionFileServer(winLabel)
const newUrl = `http://${addr}`
url2 = `/app/extension/raycast?url=${encodeURIComponent(newUrl)}&extPath=${encodeURIComponent(ext.extPath)}`
setIframeExtParams(ext.extPath, newUrl)
}
localStorage.setItem(
"kunkun-iframe-ext-params",
JSON.stringify({ url, extPath: ext.extPath } satisfies KunkunIframeExtParams)
)
const window = launchNewExtWindow(winLabel, url2, cmd.window)
window.onCloseRequested(async (event) => {
await winExtMap.unregisterExtensionFromWindow(winLabel)
})
} else {
console.log("Launch main window")
const winLabel = await winExtMap.registerExtensionWithWindow({
windowLabel: "main",
extPath: ext.extPath,
dist: cmd.dist
})
const _platform = platform()
if ((_platform === "windows" || _platform === "linux") && !useDevMain) {
const addr = await spawnExtensionFileServer(winLabel) // addr has format "127.0.0.1:<port>"
console.log("Extension file server address: ", addr)
const newUrl = `http://${addr}`
url2 = `/app/extension/raycast?url=${encodeURIComponent(newUrl)}&extPath=${encodeURIComponent(ext.extPath)}`
setIframeExtParams(ext.extPath, newUrl)
}
goto(i18n.resolveRoute(url2))
}
appState.clearSearchTerm()
}

View file

@ -7,7 +7,12 @@ import {
} from "@kksh/api/models"
import type { CommandLaunchers, OnExtCmdSelect } from "@kksh/ui/types"
import * as v from "valibot"
import { onCustomUiCmdSelect, onHeadlessCmdSelect, onTemplateUiCmdSelect } from "./ext"
import {
onCustomUiCmdSelect,
onHeadlessCmdSelect,
onRaycastCmdSelect,
onTemplateUiCmdSelect
} from "./ext"
import { onQuickLinkSelect } from "./quick-links"
const onExtCmdSelect: OnExtCmdSelect = (
@ -25,6 +30,9 @@ const onExtCmdSelect: OnExtCmdSelect = (
case CmdTypeEnum.HeadlessWorker:
onHeadlessCmdSelect(ext, v.parse(HeadlessCmd, cmd), { isDev, hmr })
break
case CmdTypeEnum.Raycast:
onRaycastCmdSelect(ext, v.parse(CustomUiCmd, cmd), { isDev, hmr })
break
default:
console.error("Unknown command type", cmd.type)
}

View file

@ -0,0 +1,473 @@
<script lang="ts">
import DanceTransition from "@/components/dance/dance-transition.svelte"
import { i18n } from "@/i18n"
import { appConfig, winExtMap } from "@/stores"
import { helperAPI } from "@/utils/helper"
import { paste } from "@/utils/hotkey"
import { goBackOnEscape } from "@/utils/key"
import { goHome } from "@/utils/route"
import { positionToCssStyleString, positionToTailwindClasses } from "@/utils/style"
import { sleep } from "@/utils/time"
import { isInMainWindow } from "@/utils/window"
import { ThemeColor, type Position } from "@kksh/api/models"
import {
constructJarvisServerAPIWithPermissions,
// exposeApiToWindow,
type IApp
} from "@kksh/api/ui"
import { toast, type IUiCustomServer1, type IUiCustomServer2 } from "@kksh/api/ui/custom"
import { db } from "@kksh/drizzle"
import raycastScript from "@kksh/raycast/host?raw"
import { Button } from "@kksh/svelte5"
import { cn } from "@kksh/ui/utils"
import type { IKunkunFullServerAPI } from "@kunkunapi/src/api/server"
import {
RECORD_EXTENSION_PROCESS_EVENT,
type IRecordExtensionProcessEvent
} from "@kunkunapi/src/events"
import { emitTo } from "@tauri-apps/api/event"
import { appCacheDir, BaseDirectory, join } from "@tauri-apps/api/path"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs"
import WebSocket from "@tauri-apps/plugin-websocket"
import { goto } from "$app/navigation"
import { WebSocketClientIO } from "kkrpc"
import { IframeParentIO, RPCChannel, type IoInterface } from "kkrpc/browser"
import { ArrowLeftIcon, MoveIcon, RefreshCcwIcon, XIcon } from "lucide-svelte"
import { onDestroy, onMount } from "svelte"
import { Command } from "tauri-plugin-shellx-api"
import type { PageData } from "./$types"
let { data }: { data: PageData } = $props()
const { loadedExt, extPath, extInfoInDB } = data
let root
let extSpawnedProcesses = $state<number[]>([])
const appWin = getCurrentWindow()
let iframeRef: HTMLIFrameElement
let uiControl = $state<{
iframeLoaded: boolean
showBackBtn: boolean
showMoveBtn: boolean
showRefreshBtn: boolean
backBtnPosition: Position
moveBtnPosition: Position
refreshBtnPosition: Position
transparentBg: boolean
}>({
iframeLoaded: true,
showBackBtn: true, // if open in new window, hide back button
showMoveBtn: false,
showRefreshBtn: false,
backBtnPosition: "top-left",
moveBtnPosition: "bottom-left",
refreshBtnPosition: "top-right",
transparentBg: false
})
const iframeUiAPI: IUiCustomServer2 = {
goBack: async () => {
if (isInMainWindow()) {
goto(i18n.resolveRoute("/app/"))
} else {
appWin.close()
}
},
hideBackButton: async () => {
uiControl.showBackBtn = false
},
hideMoveButton: async () => {
uiControl.showMoveBtn = false
},
hideRefreshButton: async () => {
console.log("hideRefreshButton")
uiControl.showRefreshBtn = false
},
showBackButton: async (position?: Position) => {
console.log("showBackBtn", position)
uiControl.showBackBtn = true
uiControl.backBtnPosition = position ?? "top-left"
},
showMoveButton: async (position?: Position) => {
uiControl.showMoveBtn = true
uiControl.moveBtnPosition = position ?? "bottom-left"
},
showRefreshButton: async (position?: Position) => {
uiControl.showRefreshBtn = true
uiControl.refreshBtnPosition = position ?? "top-right"
},
getTheme: () => {
const theme = $appConfig.theme
return Promise.resolve({
theme: theme.theme as ThemeColor,
radius: theme.radius,
lightMode: theme.lightMode
})
},
async reloadPage() {
location.reload()
},
async setTransparentWindowBackground(transparent: boolean) {
if (isInMainWindow()) {
throw new Error("Cannot set background in main window")
}
if (transparent) {
document.body.style.backgroundColor = "transparent"
} else {
document.body.style.backgroundColor = ""
}
}
}
const serverAPI: IKunkunFullServerAPI = constructJarvisServerAPIWithPermissions(
loadedExt.kunkun.permissions,
loadedExt.extPath,
{
recordSpawnedProcess: async (pid: number) => {
extSpawnedProcesses = [...extSpawnedProcesses, pid]
// winExtMap.registerProcess(appWin.label, pid)
const curWin = await getCurrentWindow()
await emitTo("main", RECORD_EXTENSION_PROCESS_EVENT, {
windowLabel: curWin.label,
pid
} satisfies IRecordExtensionProcessEvent)
// TODO: record process in a store
},
getSpawnedProcesses: () => Promise.resolve(extSpawnedProcesses),
paste: async () => {
await appWin.hide()
await sleep(200)
return paste()
}
}
)
const serverAPI2 = {
...serverAPI,
iframeUi: {
...serverAPI.iframeUi,
...iframeUiAPI
} satisfies IUiCustomServer1 & IUiCustomServer2,
helper: helperAPI,
db: new db.JarvisExtDB(extInfoInDB.extId),
kv: new db.KV(extInfoInDB.extId),
app: {
language: () => Promise.resolve("en") // TODO: get locale
} satisfies IApp
}
const elements = new Map()
const waiting = new Map()
function getElement(id: number): Promise<HTMLElement> {
return new Promise((resolve) => {
if (elements.has(id)) return resolve(elements.get(id))
if (!waiting.has(id)) waiting.set(id, [])
waiting.get(id).push(resolve)
})
}
function getElements(...ids: number[]) {
return Promise.all(ids.map(getElement))
}
let eventId = 0
const events = new Map()
;(async () => {
await mkdir("", { baseDir: BaseDirectory.AppCache, recursive: true })
await writeTextFile("raycast.js", raycastScript, {
baseDir: BaseDirectory.AppCache
})
const path = await join(await appCacheDir(), "raycast.js")
const node = Command.create("node", [path], {
cwd: extPath
})
const nodeProcess = await node.spawn()
const { pid } = nodeProcess
extSpawnedProcesses = [...extSpawnedProcesses, pid]
const curWin = await getCurrentWindow()
await emitTo("main", RECORD_EXTENSION_PROCESS_EVENT, {
windowLabel: curWin.label,
pid
} satisfies IRecordExtensionProcessEvent)
node.on("error", console.error)
node.stderr.on("data", console.error)
await new Promise((resolve) => {
node.stdout.on("data", (data) => {
resolve(data)
})
})
const { readable, writable } = new TransformStream()
const reader = readable.getReader()
const writer = writable.getWriter()
node.stdout.on("data", (data) => writer.write(data))
const RPC_METHOD: string = "stdio"
let stdio: IoInterface
if (RPC_METHOD === "websocket") {
const ws = await WebSocket.connect("ws://127.0.0.1:5000")
ws.addListener((message) => {
writer.write(message.data)
})
stdio = {
name: "websocket",
async read() {
const { value } = await reader.read()
return value
},
write: (data) => ws.send(data)
}
} else {
stdio = {
name: "stdio",
async read(): Promise<string | Uint8Array | null> {
const { value } = await reader.read()
return value
},
async write(data: string): Promise<void> {
return nodeProcess.write(data + "\n")
}
}
}
new RPCChannel<{}, {}>(stdio, {
expose: {
createInstance(id, type) {
// console.log("set element", id)
let element
if (["svg", "path"].includes(type))
element = document.createElementNS("http://www.w3.org/2000/svg", type)
else element = document.createElement(type)
element._id = id
elements.set(id, element)
for (const resolve of waiting.get(id) ?? []) {
resolve(element)
}
waiting.delete(id)
},
createTextInstance(id, text) {
const element = document.createTextNode(text)
elements.set(id, element)
element._id = id
for (const resolve of waiting.get(id) ?? []) {
resolve(element)
}
waiting.delete(id)
},
async appendChild(parentId, childId) {
const [parent, child] = await getElements(parentId, childId)
parent.appendChild(child)
},
async removeChild(parentId, childId) {
const [parent, child] = await getElements(parentId, childId)
parent.removeChild(child)
function removeRecursive(elementId, element) {
for (const child of element.children) {
removeRecursive(child._id, child)
}
elements.delete(elementId)
if (waiting.has(elementId)) waiting.delete(elementId)
}
removeRecursive(childId, child)
},
async insertBefore(parentId, childId, beforeChildId) {
const [parent, child, beforeChild] = await getElements(parentId, childId, beforeChildId)
parent.insertBefore(child, beforeChild)
},
async setText(elementId, text) {
const element = await getElement(elementId)
element.textContent = text
},
async applyProps(elementId, props) {
const element = await getElement(elementId)
for (let [propName, propValue] of Object.entries(props)) {
if (propName === "className") propName = "class"
if (propName === "children") {
if (typeof propValue === "string" || typeof propValue === "number") {
element.textContent = propValue.toString()
}
} else if (propName.startsWith("on")) {
element.addEventListener(propName.slice(2).toLowerCase(), async (event) => {
const id = eventId++
const serialized = {
target: {
value: event.target.value
},
key: event.key,
ctrlKey: event.ctrlKey,
keyCode: event.keyCode,
id
}
events.set(id, event)
await new Promise((resolve) => {
event.__resolve = resolve
propValue(serialized)
})
event.__resolve()
events.delete(id)
})
} else if (propName === "style") {
for (const [rule, value] of Object.entries(propValue)) {
element.style[rule] = value
}
} else if (propValue === undefined) element.removeAttribute(propName)
else element.setAttribute(propName, propValue)
}
},
async addEventListener(elementId, type, listener) {
const element = await getElement(elementId)
// Since React only allows one event listener per type, we can take advantage of that
// by storing the listener in the element itself
if (element._listeners == null) element._listeners = {}
const existing = element._listeners[type]
if (existing) element.removeEventListener(type, existing)
const realListener = async (event) => {
const id = eventId++
const serialized = {
target: {
value: event.target.value
},
key: event.key,
ctrlKey: event.ctrlKey,
keyCode: event.keyCode,
id
}
events.set(id, event)
await new Promise((resolve) => {
event.__resolve = resolve
listener(serialized)
})
}
element.addEventListener(type, realListener)
element._listeners[type] = realListener
},
async preventDefault(id) {
const event = events.get(id)
if (event) {
event.preventDefault()
}
},
async clearEvent(id) {
const event = events.get(id)
if (event) {
event.__resolve()
events.delete(id)
}
}
}
})
})()
function onBackBtnClicked() {
if (isInMainWindow()) {
goHome()
} else {
appWin.close()
}
}
function onIframeLoaded() {
setTimeout(() => {
iframeRef.focus()
uiControl.iframeLoaded = true
}, 300)
}
onMount(() => {
elements.set(-1, root)
setTimeout(() => {
appWin.show()
}, 200)
if (iframeRef?.contentWindow) {
const io = new IframeParentIO(iframeRef.contentWindow)
const rpc = new RPCChannel(io, { expose: serverAPI2 })
} else {
toast.warning("iframeRef.contentWindow not available")
}
setTimeout(() => {
if (!uiControl.iframeLoaded) {
toast.error("Extension failed to load")
}
}, 3_000)
})
onDestroy(() => {
winExtMap.unregisterExtensionFromWindow(appWin.label)
})
</script>
<svelte:window on:keydown={goBackOnEscape} />
{#if uiControl.backBtnPosition && uiControl.showBackBtn}
<Button
class={cn("absolute", positionToTailwindClasses(uiControl.backBtnPosition))}
size="icon"
variant="outline"
onclick={onBackBtnClicked}
style={`${positionToCssStyleString(uiControl.backBtnPosition)}`}
>
{#if appWin.label === "main"}
<ArrowLeftIcon class="w-4" />
{:else}
<XIcon class="w-4" />
{/if}
</Button>
{/if}
{#if uiControl.moveBtnPosition && uiControl.showMoveBtn}
<Button
class={cn("absolute", positionToTailwindClasses(uiControl.moveBtnPosition))}
style={`${positionToCssStyleString(uiControl.moveBtnPosition)}`}
size="icon"
variant="outline"
data-tauri-drag-region
>
<MoveIcon data-tauri-drag-region class="w-4" />
</Button>
{/if}
{#if uiControl.refreshBtnPosition && uiControl.showRefreshBtn}
<Button
class={cn("absolute", positionToTailwindClasses(uiControl.refreshBtnPosition))}
style={`${positionToCssStyleString(uiControl.refreshBtnPosition)}`}
size="icon"
variant="outline"
onclick={iframeUiAPI.reloadPage}
>
<RefreshCcwIcon class="w-4" />
</Button>
{/if}
<main class="h-screen">
<DanceTransition delay={300} autoHide={false} show={!uiControl.iframeLoaded} />
<div bind:this={root}></div>
<!-- <iframe
bind:this={iframeRef}
class={cn("h-full", {
hidden: !uiControl.iframeLoaded
})}
onload={onIframeLoaded}
width="100%"
height="100%"
frameborder="0"
src={data.url}
title={data.extPath}
></iframe> -->
</main>

View file

@ -0,0 +1,71 @@
import { KunkunIframeExtParams } from "@/cmds/ext"
import { i18n } from "@/i18n"
import type { Ext as ExtInfoInDB, ExtPackageJsonExtra } from "@kksh/api/models"
import { db } from "@kksh/drizzle"
import { loadExtensionManifestFromDisk } from "@kksh/extension"
import { error as svError } from "@sveltejs/kit"
import { join } from "@tauri-apps/api/path"
import { error } from "@tauri-apps/plugin-log"
import { goto } from "$app/navigation"
import { toast } from "svelte-sonner"
import * as v from "valibot"
import type { PageLoad } from "./$types"
export const load: PageLoad = async ({
url
}): Promise<{
extPath: string
url: string
loadedExt: ExtPackageJsonExtra
extInfoInDB: ExtInfoInDB
}> => {
// both query parameter must exist
const rawKunkunIframeExtParams = localStorage.getItem("kunkun-iframe-ext-params")
if (!rawKunkunIframeExtParams) {
toast.error("Invalid extension path or url")
return svError(404, "Invalid extension path or url")
}
// localStorage.removeItem("kunkun-iframe-ext-params")
const parsed = v.safeParse(KunkunIframeExtParams, JSON.parse(rawKunkunIframeExtParams))
if (!parsed.success) {
toast.error("Fail to parse extension params from local storage", {
description: `${v.flatten<typeof KunkunIframeExtParams>(parsed.issues)}`
})
return svError(400, "Fail to parse extension params from local storage")
}
const { url: extUrl, extPath } = parsed.output
console.log("extUrl extPath", extUrl, extPath)
const _extPath = url.searchParams.get("extPath")
const _extUrl = url.searchParams.get("url")
console.log("_extPath", _extPath)
console.log("_extUrl", _extUrl)
// if (!_extPath || !_extUrl) {
// toast.error("Invalid extension path or url", {
// description: `_extPath: ${_extPath}; _extUrl: ${_extUrl}`
// })
// error("Invalid extension path or url")
// goto(i18n.resolveRoute("/app/"))
// }
// const extPath = z.string().parse(_extPath)
// const extUrl = z.string().parse(_extUrl)
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(i18n.resolveRoute("/app/"))
}
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(i18n.resolveRoute("/app/"))
}
return { extPath, url: extUrl, loadedExt, extInfoInDB: extInfoInDB! }
}

73
flake.nix Normal file
View file

@ -0,0 +1,73 @@
{
description = "Kunkun development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
nixpkgs,
rust-overlay,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
toolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
in
{
devShells.default = pkgs.mkShell {
packages = [
toolchain
];
nativeBuildInputs = with pkgs; [
pkg-config
gobject-introspection
cargo
cargo-tauri
pnpm
nodejs_22
cmake
xorg.libXtst
protobuf
xdotool
];
buildInputs = with pkgs; [
at-spi2-atk
atkmm
cairo
gdk-pixbuf
glib
gtk3
harfbuzz
librsvg
libsoup_3
pango
webkitgtk_4_1
openssl
deno
ffmpeg
libayatana-appindicator
];
shellHook = ''
export WEBKIT_DISABLE_COMPOSITING_MODE=1
'';
};
}
);
}

View file

@ -245,7 +245,7 @@ export class TauriShellStdio implements IoInterface {
read(): Promise<string | Uint8Array | null> {
return new Promise((resolve, reject) => {
this.readStream.on("data", (chunk) => {
this.readStream.once("data", (chunk) => {
resolve(chunk)
})
})

View file

@ -32,7 +32,8 @@ export const CmdTypeEnum = {
UiWorker: "ui_worker",
UiIframe: "ui_iframe",
QuickLink: "quick_link",
Remote: "remote"
Remote: "remote",
Raycast: "raycast"
}
export const CmdType = v.picklist(Object.values(CmdTypeEnum))

View file

@ -1,7 +1,14 @@
import { ExtPackageJson, ExtPackageJsonExtra, License } from "@kksh/api/models"
import {
CmdTypeEnum,
ExtPackageJson,
ExtPackageJsonExtra,
IconEnum,
KunkunExtManifest,
License
} from "@kksh/api/models"
import { db } from "@kksh/drizzle"
import { basename, dirname, join } from "@tauri-apps/api/path"
import { readDir, readTextFile } from "@tauri-apps/plugin-fs"
import { readDir, readFile, readTextFile } from "@tauri-apps/plugin-fs"
import { debug, error } from "@tauri-apps/plugin-log"
import semver from "semver"
import * as v from "valibot"
@ -12,6 +19,35 @@ const OptionalExtPackageJson = v.object({
license: v.optional(License, "MIT") // TODO: remove this optional package json later
})
const RaycastExtPackageJson = v.object({
...v.omit(OptionalExtPackageJson, ["kunkun"]).entries,
title: v.string(),
description: v.string(),
icon: v.string(),
version: v.optional(v.string(), "Version of the extension"),
commands: v.array(
v.object({
name: v.string(),
title: v.string(),
subtitle: v.optional(v.string()),
description: v.string(),
mode: v.picklist(["view", "no-view"])
})
)
})
// https://stackoverflow.com/a/66046176
async function bufferToBase64(buffer) {
// use a FileReader to generate a base64 data URI:
const base64url = await new Promise((r) => {
const reader = new FileReader()
reader.onload = () => r(reader.result)
reader.readAsDataURL(new Blob([buffer]))
})
// remove the `data:...;base64,` part from the start
return base64url.slice(base64url.indexOf(",") + 1)
}
export function parseAPIVersion(dependencies: Record<string, string>) {
const stripPrefix = (version: string) => version.replace(/^[^0-9]+/, "") // Remove leading ^, ~, etc.
const apiVersion = dependencies["@kksh/api"]
@ -30,6 +66,40 @@ export function loadExtensionManifestFromDisk(manifestPath: string): Promise<Ext
debug(`loadExtensionManifestFromDisk: ${manifestPath}`)
return readTextFile(manifestPath).then(async (content) => {
const json = JSON.parse(content)
const raycastParse = v.safeParse(RaycastExtPackageJson, json)
if (!raycastParse.issues) {
const raycast = raycastParse.output
json.kunkun = {
name: raycast.title,
shortDescription: raycast.description,
longDescription: "",
identifier: raycast.name,
permissions: ["shell:deno:execute", "shell:deno:spawn", "shell:all", "shell:execute"],
demoImages: [],
icon: {
// TODO: is this the best way to do this?
type: IconEnum.Base64PNG,
value: await bufferToBase64(
await readFile(await join(await dirname(manifestPath), "assets", raycast.icon))
)
},
customUiCmds: raycast.commands.map((cmd) => ({
main: "/",
dist: "dist",
name: cmd.title,
cmds: [],
type: CmdTypeEnum.Raycast,
description: cmd.description,
platforms: [],
devMain: ""
}))
} satisfies KunkunExtManifest
delete json.commands
json.version = "1.0.0"
}
const parse = v.safeParse(OptionalExtPackageJson, json)
if (parse.issues) {
error(`Fail to load extension from ${manifestPath}. See console for parse error.`)

538
packages/raycast/api.tsx Normal file
View file

@ -0,0 +1,538 @@
import {
Button,
Command,
CommandEmpty,
CommandFooter,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
VertifcalSeparator
} from "@kksh/react"
import { CogIcon, Icons } from "@raycast/icons"
import { clsx, type ClassValue } from "clsx"
import React, { useCallback } from "react"
import { twMerge } from "tailwind-merge"
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function List(props) {
const { className, children } = props
const [value, setValue] = React.useState("")
const onInput = useCallback((event) => {
const v = event.target.value
setValue(v)
props.onSearchTextChange?.(v)
}, [])
return (
<>
<Command className={cn("rounded-lg border shadow-md", className)} shouldFilter={false}>
<CommandInput placeholder="Type a command or search..." onInput={onInput} value={value} />
<CommandList className="h-full">
<CommandEmpty>No results found.</CommandEmpty>
{children}
</CommandList>
<CommandFooter>
<CogIcon className="ml-2 size-4" />
<div className="flex items-center space-x-2">
<Button variant="ghost" size="sm">
Open Application
<kbd className="ml-1"></kbd>
</Button>
<VertifcalSeparator />
{/* <ActionPanel
listRef={listRef}
selectedValue={value}
inputRef={inputRef}
actionItems={[
{ label: "Open Application", value: "open" },
{ label: "Show in Finder", value: "finder" },
{ label: "Show Info in Finder", value: "info" },
{ label: "Add to Favorites", value: "favorites" }
]}
></ActionPanel> */}
</div>
</CommandFooter>
</Command>
</>
)
}
List.Item = function (props) {
const Icon = Icons[props.icon?.slice(0, -3)]
return (
<CommandItem className="gap-3">
{Icon && <Icon style={{ width: "16px" }} />}
<span className="truncate">{props.title}</span>
<span className="text-muted-foreground">{props.subtitle}</span>
</CommandItem>
)
}
List.Section = CommandGroup
export function ActionPanel() {
return <div></div>
}
ActionPanel.Section = ActionPanel
export function Action() {}
Action.CopyToClipboard = Action
export function showToast() {
console.error("showToast stub")
return {}
}
enum Style {
Animated
}
export const Toast = {
Style
}
export function useNavigation() {
return {
push: () => console.error("push stub"),
pop: () => console.error("pop stub")
}
}
export function getPreferenceValues() {
return {}
}
// TODO: this should probably just be re-exported from @raycast/api
export enum Icon {
AddPerson = "add-person-16",
Airplane = "airplane-16",
AirplaneFilled = "airplane-filled-16",
AirplaneLanding = "airplane-landing-16",
AirplaneTakeoff = "airplane-takeoff-16",
Airpods = "airpods-16",
Alarm = "alarm-16",
AlarmRinging = "alarm-ringing-16",
AlignCentre = "align-centre-16",
AlignLeft = "align-left-16",
AlignRight = "align-right-16",
AmericanFootball = "american-football-16",
Anchor = "anchor-16",
AppWindow = "app-window-16",
AppWindowGrid2x2 = "app-window-grid-2x2-16",
AppWindowGrid3x3 = "app-window-grid-3x3-16",
AppWindowList = "app-window-list-16",
AppWindowSidebarLeft = "app-window-sidebar-left-16",
AppWindowSidebarRight = "app-window-sidebar-right-16",
ArrowClockwise = "arrow-clockwise-16",
ArrowCounterClockwise = "arrow-counter-clockwise-16",
ArrowDown = "arrow-down-16",
ArrowDownCircle = "arrow-down-circle-16",
ArrowDownCircleFilled = "arrow-down-circle-filled-16",
ArrowLeft = "arrow-left-16",
ArrowLeftCircle = "arrow-left-circle-16",
ArrowLeftCircleFilled = "arrow-left-circle-filled-16",
ArrowNe = "arrow-ne-16",
ArrowRight = "arrow-right-16",
ArrowRightCircle = "arrow-right-circle-16",
ArrowRightCircleFilled = "arrow-right-circle-filled-16",
ArrowUp = "arrow-up-16",
ArrowUpCircle = "arrow-up-circle-16",
ArrowUpCircleFilled = "arrow-up-circle-filled-16",
AtSymbol = "at-symbol-16",
BandAid = "band-aid-16",
BankNote = "bank-note-16",
BarChart = "bar-chart-16",
BarCode = "bar-code-16",
BathTub = "bath-tub-16",
Battery = "battery-16",
BatteryCharging = "battery-charging-16",
BatteryDisabled = "battery-disabled-16",
Bell = "bell-16",
BellDisabled = "bell-disabled-16",
Bike = "bike-16",
Binoculars = "binoculars-16",
Bird = "bird-16",
BlankDocument = "blank-document-16",
Bluetooth = "bluetooth-16",
Boat = "boat-16",
Bold = "bold-16",
Bolt = "bolt-16",
BoltDisabled = "bolt-disabled-16",
Book = "book-16",
Bookmark = "bookmark-16",
Box = "box-16",
Brush = "brush-16",
Bubble = "speech-bubble-16",
Bug = "bug-16",
BulletPoints = "bullet-points-16",
BullsEye = "bulls-eye-16",
Buoy = "buoy-16",
Calculator = "calculator-16",
Calendar = "calendar-16",
Camera = "camera-16",
Car = "car-16",
Cart = "cart-16",
Cd = "cd-16",
Center = "center-16",
Check = "check-16",
CheckCircle = "check-circle-16",
Checkmark = "check-circle-16",
ChessPiece = "chess-piece-16",
ChevronDown = "chevron-down-16",
ChevronDownSmall = "chevron-down-small-16",
ChevronLeft = "chevron-left-16",
ChevronLeftSmall = "chevron-left-small-16",
ChevronRight = "chevron-right-16",
ChevronRightSmall = "chevron-right-small-16",
ChevronUp = "chevron-up-16",
ChevronUpSmall = "chevron-up-small-16",
Circle = "circle-16",
CircleEllipsis = "circle-ellipsis-16",
CircleFilled = "circle-filled-16",
CircleProgress = "circle-progress-16",
CircleProgress100 = "circle-progress-100-16",
CircleProgress25 = "circle-progress-25-16",
CircleProgress50 = "circle-progress-50-16",
CircleProgress75 = "circle-progress-75-16",
ClearFormatting = "clear-formatting-16",
Clipboard = "copy-clipboard-16",
Clock = "clock-16",
Cloud = "cloud-16",
CloudLightning = "cloud-lightning-16",
CloudRain = "cloud-rain-16",
CloudSnow = "cloud-snow-16",
CloudSun = "cloud-sun-16",
Code = "code-16",
CodeBlock = "code-block-16",
Cog = "cog-16",
Coin = "coin-16",
Coins = "coins-16",
Compass = "compass-16",
ComputerChip = "computer-chip-16",
Contrast = "contrast-16",
CopyClipboard = "copy-clipboard-16",
CreditCard = "credit-card-16",
CricketBall = "cricket-ball-16",
Crop = "crop-16",
Crown = "crown-16",
Crypto = "crypto-16",
DeleteDocument = "delete-document-16",
Desktop = "desktop-16",
Dna = "dna-16",
Document = "blank-document-16",
Dot = "dot-16",
Download = "download-16",
EditShape = "edit-shape-16",
Eject = "eject-16",
Ellipsis = "ellipsis-16",
Emoji = "emoji-16",
Envelope = "envelope-16",
Eraser = "eraser-16",
ExclamationMark = "important-01-16",
Exclamationmark = "exclamationmark-16",
Exclamationmark2 = "exclamationmark-2-16",
Exclamationmark3 = "exclamationmark-3-16",
Eye = "eye-16",
EyeDisabled = "eye-disabled-16",
EyeDropper = "eye-dropper-16",
Female = "female-16",
FilmStrip = "film-strip-16",
Filter = "filter-16",
Finder = "finder-16",
Fingerprint = "fingerprint-16",
Folder = "folder-16",
Footprints = "footprints-16",
Forward = "forward-16",
ForwardFilled = "forward-filled-16",
FountainTip = "fountain-tip-16",
FullSignal = "full-signal-16",
GameController = "game-controller-16",
Gauge = "gauge-16",
Gear = "cog-16",
Geopin = "geopin-16",
Germ = "germ-16",
Gift = "gift-16",
Glasses = "glasses-16",
Globe = "globe-01-16",
Goal = "goal-16",
Hammer = "hammer-16",
HardDrive = "hard-drive-16",
Hashtag = "hashtag-16",
Headphones = "headphones-16",
Heart = "heart-16",
HeartDisabled = "heart-disabled-16",
Heartbeat = "heartbeat-16",
Highlight = "highlight-16",
Hourglass = "hourglass-16",
House = "house-16",
Image = "image-16",
Important = "important-01-16",
Info = "info-01-16",
Italics = "italics-16",
Key = "key-16",
Keyboard = "keyboard-16",
Layers = "layers-16",
Leaderboard = "leaderboard-16",
Leaf = "leaf-16",
LevelMeter = "signal-2-16",
LightBulb = "light-bulb-16",
LightBulbOff = "light-bulb-off-16",
LineChart = "line-chart-16",
Link = "link-16",
List = "app-window-list-16",
Livestream = "livestream-01-16",
LivestreamDisabled = "livestream-disabled-01-16",
Lock = "lock-16",
LockDisabled = "lock-disabled-16",
LockUnlocked = "lock-unlocked-16",
Logout = "logout-16",
Lorry = "lorry-16",
Lowercase = "lowercase-16",
MagnifyingGlass = "magnifying-glass-16",
Male = "male-16",
Map = "map-16",
Mask = "mask-16",
Maximize = "maximize-16",
MedicalSupport = "medical-support-16",
Megaphone = "megaphone-16",
MemoryChip = "computer-chip-16",
MemoryStick = "memory-stick-16",
Message = "speech-bubble-16",
Microphone = "microphone-16",
MicrophoneDisabled = "microphone-disabled-16",
Minimize = "minimize-16",
Minus = "minus-16",
MinusCircle = "minus-circle-16",
MinusCircleFilled = "minus-circle-filled-16",
Mobile = "mobile-16",
Monitor = "monitor-16",
Moon = "moon-16",
Mountain = "mountain-16",
Mouse = "mouse-16",
Multiply = "multiply-16",
Music = "music-16",
Network = "network-16",
NewDocument = "new-document-16",
NewFolder = "new-folder-16",
Number00 = "number-00-16",
Number01 = "number-01-16",
Number02 = "number-02-16",
Number03 = "number-03-16",
Number04 = "number-04-16",
Number05 = "number-05-16",
Number06 = "number-06-16",
Number07 = "number-07-16",
Number08 = "number-08-16",
Number09 = "number-09-16",
Number10 = "number-10-16",
Number11 = "number-11-16",
Number12 = "number-12-16",
Number13 = "number-13-16",
Number14 = "number-14-16",
Number15 = "number-15-16",
Number16 = "number-16-16",
Number17 = "number-17-16",
Number18 = "number-18-16",
Number19 = "number-19-16",
Number20 = "number-20-16",
Number21 = "number-21-16",
Number22 = "number-22-16",
Number23 = "number-23-16",
Number24 = "number-24-16",
Number25 = "number-25-16",
Number26 = "number-26-16",
Number27 = "number-27-16",
Number28 = "number-28-16",
Number29 = "number-29-16",
Number30 = "number-30-16",
Number31 = "number-31-16",
Number32 = "number-32-16",
Number33 = "number-33-16",
Number34 = "number-34-16",
Number35 = "number-35-16",
Number36 = "number-36-16",
Number37 = "number-37-16",
Number38 = "number-38-16",
Number39 = "number-39-16",
Number40 = "number-40-16",
Number41 = "number-41-16",
Number42 = "number-42-16",
Number43 = "number-43-16",
Number44 = "number-44-16",
Number45 = "number-45-16",
Number46 = "number-46-16",
Number47 = "number-47-16",
Number48 = "number-48-16",
Number49 = "number-49-16",
Number50 = "number-50-16",
Number51 = "number-51-16",
Number52 = "number-52-16",
Number53 = "number-53-16",
Number54 = "number-54-16",
Number55 = "number-55-16",
Number56 = "number-56-16",
Number57 = "number-57-16",
Number58 = "number-58-16",
Number59 = "number-59-16",
Number60 = "number-60-16",
Number61 = "number-61-16",
Number62 = "number-62-16",
Number63 = "number-63-16",
Number64 = "number-64-16",
Number65 = "number-65-16",
Number66 = "number-66-16",
Number67 = "number-67-16",
Number68 = "number-68-16",
Number69 = "number-69-16",
Number70 = "number-70-16",
Number71 = "number-71-16",
Number72 = "number-72-16",
Number73 = "number-73-16",
Number74 = "number-74-16",
Number75 = "number-75-16",
Number76 = "number-76-16",
Number77 = "number-77-16",
Number78 = "number-78-16",
Number79 = "number-79-16",
Number80 = "number-80-16",
Number81 = "number-81-16",
Number82 = "number-82-16",
Number83 = "number-83-16",
Number84 = "number-84-16",
Number85 = "number-85-16",
Number86 = "number-86-16",
Number87 = "number-87-16",
Number88 = "number-88-16",
Number89 = "number-89-16",
Number90 = "number-90-16",
Number91 = "number-91-16",
Number92 = "number-92-16",
Number93 = "number-93-16",
Number94 = "number-94-16",
Number95 = "number-95-16",
Number96 = "number-96-16",
Number97 = "number-97-16",
Number98 = "number-98-16",
Number99 = "number-99-16",
Paperclip = "paperclip-16",
Patch = "patch-16",
Pause = "pause-16",
PauseFilled = "pause-filled-16",
Pencil = "pencil-16",
Person = "person-16",
PersonCircle = "person-circle-16",
PersonLines = "person-lines-16",
Phone = "phone-16",
PhoneRinging = "phone-ringing-16",
PieChart = "pie-chart-16",
Pill = "pill-16",
Pin = "pin-16",
PinDisabled = "pin-disabled-16",
Play = "play-16",
PlayFilled = "play-filled-16",
Plug = "plug-16",
Plus = "plus-16",
PlusCircle = "plus-circle-16",
PlusCircleFilled = "plus-circle-filled-16",
PlusMinusDivideMultiply = "plus-minus-divide-multiply-16",
Power = "power-16",
Print = "print-16",
QuestionMark = "question-mark-circle-16",
QuestionMarkCircle = "question-mark-circle-16",
QuotationMarks = "quotation-marks-16",
QuoteBlock = "quote-block-16",
Racket = "racket-16",
Raindrop = "raindrop-16",
RaycastLogoNeg = "raycast-logo-neg-16",
RaycastLogoPos = "raycast-logo-pos-16",
Receipt = "receipt-16",
Redo = "redo-16",
RemovePerson = "remove-person-16",
Repeat = "repeat-16",
Reply = "reply-16",
Rewind = "rewind-16",
RewindFilled = "rewind-filled-16",
Rocket = "rocket-16",
Rosette = "rosette-16",
RotateAntiClockwise = "rotate-anti-clockwise-16",
RotateClockwise = "rotate-clockwise-16",
Ruler = "ruler-16",
SaveDocument = "save-document-16",
Shield = "shield-01-16",
Shuffle = "shuffle-16",
Sidebar = "app-window-sidebar-right-16",
Signal1 = "signal-1-16",
Signal2 = "signal-2-16",
Signal3 = "signal-3-16",
Snippets = "snippets-16",
Snowflake = "snowflake-16",
SoccerBall = "soccer-ball-16",
SpeakerDown = "speaker-down-16",
SpeakerHigh = "speaker-high-16",
SpeakerLow = "speaker-low-16",
SpeakerOff = "speaker-off-16",
SpeakerOn = "speaker-on-16",
SpeakerUp = "speaker-up-16",
SpeechBubble = "speech-bubble-16",
SpeechBubbleActive = "speech-bubble-active-16",
SpeechBubbleImportant = "speech-bubble-important-16",
Star = "star-16",
StarCircle = "star-circle-16",
StarDisabled = "star-disabled-16",
Stars = "stars-16",
Stop = "stop-16",
StopFilled = "stop-filled-16",
Stopwatch = "stopwatch-16",
Store = "store-16",
StrikeThrough = "strike-through-16",
Sun = "sun-16",
Sunrise = "sunrise-16",
Swatch = "swatch-16",
Switch = "switch-16",
Syringe = "syringe-16",
Tag = "tag-16",
Temperature = "temperature-16",
TennisBall = "tennis-ball-16",
Terminal = "terminal-16",
Text = "text-16",
TextCursor = "text-cursor-16",
Torch = "torch-16",
Train = "train-16",
Trash = "trash-16",
Tray = "tray-16",
Tree = "tree-16",
Trophy = "trophy-16",
TwoPeople = "two-people-16",
Umbrella = "umbrella-16",
Underline = "underline-16",
Undo = "undo-16",
Upload = "upload-16",
Uppercase = "uppercase-16",
Video = "video-16",
Wallet = "wallet-16",
Wand = "wand-16",
Warning = "warning-16",
Weights = "weights-16",
Wifi = "wifi-16",
WifiDisabled = "wifi-disabled-16",
Window = "app-window-16",
WrenchScrewdriver = "wrench-screwdriver-16",
WristWatch = "wrist-watch-16",
XMarkCircle = "x-mark-circle-16",
XMarkCircleFilled = "x-mark-circle-filled-16",
/** @deprecated Use {@link Icon.ArrowClockwise} instead. */
TwoArrowsClockwise = "arrow-clockwise-16",
/** @deprecated Use {@link Icon.EyeDisabled} instead. */
EyeSlash = "eye-disabled-16",
/** @deprecated Use {@link Icon.SpeakerDown} instead. */
SpeakerArrowDown = "speaker-down-16",
/** @deprecated Use {@link Icon.SpeakerUp} instead. */
SpeakerArrowUp = "speaker-up-16",
/** @deprecated Use {@link Icon.SpeakerOff} instead. */
SpeakerSlash = "speaker-off-16",
/** @deprecated Use {@link Icon.BlankDocument} instead. */
TextDocument = "blank-document-16",
/** @deprecated Use {@link Icon.XMarkCircle} instead. */
XmarkCircle = "x-mark-circle-16"
}

70
packages/raycast/host.tsx Normal file
View file

@ -0,0 +1,70 @@
import module from "module"
import { TransformStream } from "stream/web"
import { IoInterface, NodeIo, RPCChannel, WebSocketServerIO } from "kkrpc"
import react from "react"
import reactRuntime from "react/jsx-runtime"
import { WebSocketServer } from "ws"
import * as raycast from "./api"
import reconciler from "./reconciler"
module.prototype.require = new Proxy(module.prototype.require, {
apply(target, thisArg, argumentsList) {
const replacements = {
react,
"react/jsx-runtime": reactRuntime,
"@raycast/api": raycast
}
const replacement = replacements[argumentsList[0]]
if (replacement) {
return replacement
}
return Reflect.apply(target, thisArg, argumentsList)
}
})
const { readable, writable } = new TransformStream()
const reader = readable.getReader()
const writer = writable.getWriter()
process.stdin.on("data", (data) => writer.write(data))
const RPC_METHOD = "stdio"
if (RPC_METHOD === "stdio") {
// const stdio = new NodeIo(process.stdin, process.stdout)
const stdio: IoInterface = {
name: "stdio",
async read() {
const { value } = await reader.read()
return value
},
write(data) {
return new Promise((resolve) => process.stdout.write(data, () => resolve()))
}
}
init(stdio)
} else {
const wss = new WebSocketServer({ port: 5000 })
console.log("WebSocket server started on ws://localhost:5000")
wss.on("connection", (ws) => {
const stdio = new WebSocketServerIO(ws)
init(stdio)
})
}
function init(stdio) {
const child = new RPCChannel(stdio)
const originalCallMethod = child.callMethod
child.callMethod = function (method, args) {
console.log("callMethod", method, JSON.stringify(args))
return originalCallMethod.call(this, method, args)
}
const api = child.getAPI()
;(async () => {
const app = await import(`${process.cwd()}/dist/index.js`)
reconciler.render(<app.default.default></app.default.default>, api)
})()
}

View file

@ -0,0 +1,31 @@
{
"name": "@kksh/raycast",
"module": "index.ts",
"type": "module",
"exports": {
".": "./api.tsx",
"./host": "./dist/host.js"
},
"scripts": {
"build": "bun build --target=node --external=./index.js --format=cjs --outdir=dist ./host.tsx",
"dev": "bun run build -- --watch"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@kksh/react": "^0.1.1",
"@raycast/icons": "^0.4.7",
"clsx": "^2.1.1",
"kkrpc": "^0.2.2",
"react": "^18.0.0",
"react-reconciler": "^0.28.0",
"tailwind-merge": "^3.0.2",
"ws": "^8.18.1"
},
"devDependencies": {
"@types/node": "^22.15.2",
"@types/react": "^18.0.10",
"@types/react-reconciler": "^0.28.0"
}
}

View file

@ -0,0 +1,150 @@
import ReactReconciler from "react-reconciler"
const rootHostContext = {}
const childHostContext = {}
let elementID = 0
function shallowDiff(oldObj, newObj) {
// Return a diff between the new and the old object
const uniqueProps = new Set([...Object.keys(oldObj), ...Object.keys(newObj)])
const changedProps = Array.from(uniqueProps).filter(
(propName) => oldObj[propName] !== newObj[propName]
)
return changedProps.length === 0 ? null : changedProps
}
function makeHostConfig(
api
): ReactReconciler.HostConfig<string, {}, any, any, any, any, any, any, any, any, any, any, any> {
function applyProps(element, props2) {
const props = { ...props2 }
if (typeof props.children !== "string" && typeof props.children !== "number") {
delete props.children
}
for (let [propName, propValue] of Object.entries(props)) {
if (propName.startsWith("on")) {
api.addEventListener(element, propName.slice(2).toLowerCase(), (event) => {
event.preventDefault = () => api.preventDefault(event.id)
propValue(event)
api.clearEvent(event.id)
})
delete props[propName]
}
}
if (Object.keys(props).length > 0) api.applyProps(element, props)
}
return {
now: Date.now,
getRootHostContext: () => {
return rootHostContext
},
prepareForCommit: () => {},
resetAfterCommit: () => {},
getChildHostContext: () => {
return childHostContext
},
shouldSetTextContent: (type, props) => {
return typeof props.children === "string" || typeof props.children === "number"
},
/**
This is where react-reconciler wants to create an instance of UI element in terms of the target. Since our target here is the DOM, we will create document.createElement and type is the argument that contains the type string like div or img or h1 etc. The initial values of domElement attributes can be set in this function from the newProps argument
*/
createInstance: (
type,
newProps,
rootContainerInstance,
_currentHostContext,
workInProgress
) => {
const id = elementID++
// console.log("createInstance", id, type)
api.createInstance(id, type)
applyProps(id, newProps)
return id
},
createTextInstance: (text) => {
const id = elementID++
// console.log("createTextInstance", id, text)
api.createTextInstance(id, text)
return id
},
appendInitialChild: (parent, child) => {
api.appendChild(parent, child)
},
appendChild(parent, child) {
api.appendChild(parent, child)
},
insertBefore(parentInstance, child, beforeChild) {
api.insertBefore(parentInstance, child, beforeChild)
},
insertInContainerBefore(parentInstance, child, beforeChild) {
api.insertBefore(parentInstance, child, beforeChild)
},
finalizeInitialChildren: (domElement, type, props) => false,
supportsMutation: true,
appendChildToContainer: (parent, child) => {
api.appendChild(parent, child)
},
prepareUpdate(domElement, type, oldProps, newProps) {
// Return a diff between the new and the old props
return shallowDiff(oldProps, newProps)
},
commitUpdate(domElement, updatePayload, type, oldProps, newProps) {
const serialized = {}
for (const prop of updatePayload) {
if (prop === "style") {
for (const key of shallowDiff(oldProps.style, newProps.style) ?? []) {
serialized.style ??= {}
serialized.style[key] = newProps.style[key]
}
continue
}
serialized[prop] = newProps[prop]
}
applyProps(domElement, serialized)
},
commitTextUpdate(textInstance, oldText, newText) {
textInstance.nodeValue = newText
},
resetTextContent(domElement) {
api.setText(domElement, "")
},
removeChild(parentInstance, child) {
api.removeChild(parentInstance, child)
},
supportsPersistence: false,
getPublicInstance() {},
preparePortalMount() {},
scheduleTimeout: setTimeout,
isPrimaryRenderer: true,
getCurrentEventPriority() {},
getInstanceFromNode() {},
beforeActiveInstanceBlur() {},
afterActiveInstanceBlur() {},
prepareScopeUpdate() {},
getInstanceFromScope() {},
detachDeletedInstance() {},
supportsHydration: false,
cancelTimeout: clearTimeout,
noTimeout: -1,
clearContainer(container) {}
}
}
export default {
render: (reactElement, api, callback = undefined) => {
const hostConfig = makeHostConfig(api)
const ReactReconcilerInst = ReactReconciler(hostConfig)
const root = ReactReconcilerInst.createContainer(-1, false)
// update the root Container
return ReactReconcilerInst.updateContainer(reactElement, root, null, callback)
}
}

View file

@ -0,0 +1,6 @@
{
"extends": "../typescript-config/base.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}

1504
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

4
rust-toolchain.toml Normal file
View file

@ -0,0 +1,4 @@
[toolchain]
channel = "1.85"
components = [ "rustfmt", "rust-src" ]
profile = "minimal"