Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
{ "path": "$DOWNLOAD/**/*" }
]
},
"extensions:default",
"db2:default",
"windows:default",
"tracing:default",
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/components/main/body/extensions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { cn } from "@hypr/utils";

import type { Tab } from "../../../../store/zustand/tabs";
import { StandardTabWrapper } from "../index";
import { getExtensionComponent } from "./registry";
import { getExtensionComponent, getPanelInfoByExtensionId } from "./registry";

type ExtensionTab = Extract<Tab, { type: "extension" }>;

Expand Down Expand Up @@ -89,6 +89,7 @@ export function TabItemExtension({

export function TabContentExtension({ tab }: { tab: ExtensionTab }) {
const Component = getExtensionComponent(tab.extensionId);
const panelInfo = getPanelInfoByExtensionId(tab.extensionId);

if (!Component) {
return (
Expand All @@ -97,8 +98,15 @@ export function TabContentExtension({ tab }: { tab: ExtensionTab }) {
<div className="text-center">
<PuzzleIcon size={48} className="mx-auto text-neutral-300 mb-4" />
<p className="text-neutral-500">
Extension not found: {tab.extensionId}
{panelInfo
? `Extension panel "${panelInfo.title}" is registered but UI not loaded`
: `Extension not found: ${tab.extensionId}`}
</p>
{panelInfo?.entry && (
<p className="text-neutral-400 text-sm mt-2">
Entry: {panelInfo.entry}
</p>
)}
</div>
</div>
</StandardTabWrapper>
Expand Down
57 changes: 54 additions & 3 deletions apps/desktop/src/components/main/body/extensions/registry.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { ComponentType } from "react";

import { commands, type ExtensionInfo } from "@hypr/plugin-extensions";
import {
commands,
type ExtensionInfo,
type PanelInfo,
} from "@hypr/plugin-extensions";

import type { ExtensionViewProps } from "../../../../types/extensions";

// Bundled extensions are no longer included at build time.
// Extensions are expected to be loaded at runtime via the extensions plugin.
export const bundledExtensionComponents: Record<
string,
ComponentType<ExtensionViewProps>
Expand All @@ -16,6 +18,10 @@ const dynamicExtensionComponents: Record<
ComponentType<ExtensionViewProps>
> = {};

const loadedPanels: Map<string, PanelInfo> = new Map();
const extensionPanels: Map<string, PanelInfo[]> = new Map();
let panelsLoaded = false;

export function getExtensionComponent(
extensionId: string,
): ComponentType<ExtensionViewProps> | undefined {
Expand Down Expand Up @@ -55,3 +61,48 @@ export async function getExtensionsDir(): Promise<string | null> {
}
return null;
}

export function getPanelInfo(panelId: string): PanelInfo | undefined {
return loadedPanels.get(panelId);
}

export function getPanelInfoByExtensionId(
extensionId: string,
): PanelInfo | undefined {
const panels = extensionPanels.get(extensionId);
return panels?.[0];
}

export async function loadExtensionPanels(): Promise<void> {
if (panelsLoaded) {
return;
}

try {
const extensions = await listInstalledExtensions();

for (const ext of extensions) {
const panels: PanelInfo[] = [];
for (const panel of ext.panels) {
loadedPanels.set(panel.id, panel);
panels.push(panel);
}
extensionPanels.set(ext.id, panels);
}

panelsLoaded = true;
} catch (err) {
console.error("Failed to load extension panels:", err);
}
}

export function getLoadedPanels(): PanelInfo[] {
return Array.from(loadedPanels.values());
}

export function registerExtensionComponent(
extensionId: string,
component: ComponentType<ExtensionViewProps>,
): void {
dynamicExtensionComponents[extensionId] = component;
}
5 changes: 5 additions & 0 deletions apps/desktop/src/components/main/body/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { TabContentContact, TabItemContact } from "./contacts";
import { TabContentEmpty, TabItemEmpty } from "./empty";
import { TabContentEvent, TabItemEvent } from "./events";
import { TabContentExtension, TabItemExtension } from "./extensions";
import { loadExtensionPanels } from "./extensions/registry";
import { TabContentFolder, TabItemFolder } from "./folders";
import { TabContentHuman, TabItemHuman } from "./humans";
import { Search } from "./search";
Expand All @@ -38,6 +39,10 @@ export function Body() {
})),
);

useEffect(() => {
loadExtensionPanels();
}, []);

if (!currentTab) {
return null;
}
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/components/main/sidebar/profile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CalendarIcon,
ChevronUpIcon,
FolderOpenIcon,
PuzzleIcon,
SettingsIcon,
UserIcon,
UsersIcon,
Expand Down Expand Up @@ -158,11 +159,17 @@ export function ProfileSection({ onExpandChange }: ProfileSectionProps = {}) {
closeMenu();
}, [openNew, closeMenu]);

const handleClickExtension = useCallback(() => {
openNew({ type: "extension", extensionId: "hello-world" });
closeMenu();
}, [openNew, closeMenu]);

const menuItems = [
{ icon: FolderOpenIcon, label: "Folders", onClick: handleClickFolders },
{ icon: UsersIcon, label: "Contacts", onClick: handleClickContacts },
{ icon: CalendarIcon, label: "Calendar", onClick: handleClickCalendar },
{ icon: UserIcon, label: "My Profile", onClick: handleClickProfile },
{ icon: PuzzleIcon, label: "Hello World", onClick: handleClickExtension },
{
icon: SettingsIcon,
label: "Settings",
Expand Down
51 changes: 36 additions & 15 deletions crates/extensions-runtime/src/manifest.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

pub const CURRENT_API_VERSION: &str = "0.1";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionManifest {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_api_version")]
pub api_version: String,
pub entry: String,
#[serde(default)]
pub ui: Option<String>,
pub panels: Vec<PanelDeclaration>,
#[serde(default)]
pub permissions: ExtensionPermissions,
}

/// Extension permissions declaration.
/// Note: Permissions are currently not enforced. The extension runtime runs with full capabilities.
/// This struct is defined for future use when permission enforcement is implemented.
fn default_api_version() -> String {
CURRENT_API_VERSION.to_string()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelDeclaration {
pub id: String,
pub title: String,
pub entry: String,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExtensionPermissions {
#[serde(default)]
Expand Down Expand Up @@ -56,18 +69,26 @@ impl Extension {
self.path.join(&self.manifest.entry)
}

pub fn ui_path(&self) -> Option<PathBuf> {
self.manifest.ui.as_ref().and_then(|ui| {
let canonical_root = self.path.canonicalize().ok()?;
let joined = self.path.join(ui);
let canonical_ui = joined.canonicalize().ok()?;
pub fn panel_path(&self, panel_id: &str) -> Option<PathBuf> {
self.manifest
.panels
.iter()
.find(|p| p.id == panel_id)
.and_then(|panel| {
let canonical_root = self.path.canonicalize().ok()?;
let joined = self.path.join(&panel.entry);
let canonical_panel = joined.canonicalize().ok()?;

if canonical_ui.starts_with(&canonical_root) {
Some(canonical_ui)
} else {
None
}
})
if canonical_panel.starts_with(&canonical_root) {
Some(canonical_panel)
} else {
None
}
})
}

pub fn panels(&self) -> &[PanelDeclaration] {
&self.manifest.panels
}
}

Expand Down
14 changes: 14 additions & 0 deletions crates/extensions-runtime/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,17 @@ pub fn op_hypr_log(#[string] message: String) -> String {
tracing::info!(target: "extension", "{}", message);
"ok".to_string()
}

#[op2]
#[string]
pub fn op_hypr_log_error(#[string] message: String) -> String {
tracing::error!(target: "extension", "{}", message);
"ok".to_string()
}

#[op2]
#[string]
pub fn op_hypr_log_warn(#[string] message: String) -> String {
tracing::warn!(target: "extension", "{}", message);
"ok".to_string()
}
52 changes: 42 additions & 10 deletions crates/extensions-runtime/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ use deno_core::RuntimeOptions;
use std::collections::HashMap;
use tokio::sync::{mpsc, oneshot};

deno_core::extension!(hypr_extension, ops = [op_hypr_log],);
deno_core::extension!(
hypr_extension,
ops = [op_hypr_log, op_hypr_log_error, op_hypr_log_warn],
);

pub enum RuntimeRequest {
CallFunction {
Expand Down Expand Up @@ -128,8 +131,22 @@ async fn runtime_loop(mut rx: mpsc::Receiver<RuntimeRequest>) {
"<hypr:init>",
r#"
globalThis.hypr = {
log: (msg) => Deno.core.ops.op_hypr_log(String(msg)),
log: {
info: (msg) => Deno.core.ops.op_hypr_log(String(msg)),
error: (msg) => Deno.core.ops.op_hypr_log_error(String(msg)),
warn: (msg) => Deno.core.ops.op_hypr_log_warn(String(msg)),
},
_internal: {
extensionId: null,
},
};
// Backwards compatibility
hypr.log.toString = () => "[object Function]";
const originalLog = hypr.log;
globalThis.hypr.log = Object.assign(
(msg) => Deno.core.ops.op_hypr_log(String(msg)),
originalLog
);
"#,
)
.expect("Failed to initialize hypr global");
Expand Down Expand Up @@ -184,21 +201,35 @@ fn load_extension_impl(
let entry_path = extension.entry_path();
let code = std::fs::read_to_string(&entry_path)?;

let context_json = serde_json::json!({
"extensionId": extension.manifest.id,
"extensionPath": extension.path.to_string_lossy(),
"manifest": {
"id": extension.manifest.id,
"name": extension.manifest.name,
"version": extension.manifest.version,
"description": extension.manifest.description,
"apiVersion": extension.manifest.api_version,
}
});

let wrapper = format!(
r#"
(function() {{
const __hypr_extension = {{}};
{}
const __hypr_context = {context};
hypr._internal.extensionId = __hypr_context.extensionId;
{code}
if (typeof __hypr_extension.activate === 'function') {{
__hypr_extension.activate(__hypr_context);
}}
return __hypr_extension;
}})()
"#,
code
context = context_json,
code = code
);

// deno_core's execute_script requires a 'static script name for stack traces.
// We intentionally promote the extension id to 'static to satisfy this requirement.
// This leaks one small string per extension load, which is acceptable since extensions
// are loaded once and remain for the lifetime of the app.
let script_name: &'static str = Box::leak(extension.manifest.id.clone().into_boxed_str());
let result = js_runtime
.execute_script(script_name, wrapper)
Expand Down Expand Up @@ -235,9 +266,10 @@ fn load_extension_impl(
);

tracing::info!(
"Loaded extension: {} v{}",
"Loaded extension: {} v{} (API v{})",
extension.manifest.name,
extension.manifest.version
extension.manifest.version,
extension.manifest.api_version
);

Ok(())
Expand Down
2 changes: 2 additions & 0 deletions extensions/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*/dist/
node_modules/
Loading
Loading