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
33 changes: 33 additions & 0 deletions package/src/bun/ElectrobunConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,39 @@ export interface ElectrobunConfig {
* ```
*/
urlSchemes?: string[];

/**
* File type associations for the application.
* Registers document types so the OS can open files with your app
* (e.g., double-click in Finder, "Open With" menu, drag-to-dock).
*
* Platform support:
* - macOS: Fully supported. Generates CFBundleDocumentTypes in Info.plist.
* - Windows/Linux: Not yet supported.
*
* Files arrive as file:// URLs via the existing "open-url" event:
* ```typescript
* Electrobun.events.on("open-url", (e) => {
* if (e.data.url.startsWith("file://")) {
* console.log("Opened file:", e.data.url);
* }
* });
* ```
*/
fileAssociations?: Array<{
/** File extensions without the leading dot (e.g., ["dotlock", "json"]) */
ext: string[];
/** Human-readable name for this file type (e.g., "DotLock Document") */
name: string;
/** The app's role for this file type. @default "Viewer" */
role?: "Editor" | "Viewer" | "Shell" | "None";
/**
* Path to an .icns file for this document type (macOS only).
* The file is automatically copied into the app bundle's Resources folder
* during the build. Only the filename (without path) is written to Info.plist.
*/
icon?: string;
}>;
};

/**
Expand Down
183 changes: 182 additions & 1 deletion package/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1462,6 +1462,13 @@ const _commandDefaults = {
},
};

type FileAssociation = {
ext: string[];
name: string;
role?: "Editor" | "Viewer" | "Shell" | "None";
icon?: string;
};

// Default values merged with user's electrobun.config.ts
// For the user-facing type, see ElectrobunConfig in src/bun/ElectrobunConfig.ts
const defaultConfig = {
Expand All @@ -1471,6 +1478,7 @@ const defaultConfig = {
version: "0.1.0",
description: "" as string | undefined,
urlSchemes: undefined as string[] | undefined,
fileAssociations: undefined as FileAssociation[] | undefined,
},
build: {
buildFolder: "build",
Expand Down Expand Up @@ -1779,6 +1787,151 @@ ${schemesXml}
</array>`;
}

// Generates CFBundleDocumentTypes and UTExportedTypeDeclarations for file associations.
// Each association gets a UTI derived from the app identifier (e.g., com.example.app.myext).
// LSItemContentTypes in CFBundleDocumentTypes references these UTIs so Launch Services
// properly associates files with the app on modern macOS.
function generateDocumentTypes(
fileAssociations: FileAssociation[] | undefined,
projectRoot: string,
appIdentifier: string,
): string {
if (!fileAssociations || fileAssociations.length === 0) {
return "";
}

const validAssociations = fileAssociations.filter((assoc) => {
if (!assoc.ext || assoc.ext.length === 0) {
console.log(
`WARNING: fileAssociations entry "${assoc.name || "(unnamed)"}" has no extensions — skipping`,
);
return false;
}
if (!assoc.name) {
console.log(
`WARNING: fileAssociations entry with extensions [${assoc.ext.join(", ")}] has no name — skipping`,
);
return false;
}
return true;
});

if (validAssociations.length === 0) {
return "";
}

// Clean extensions and warn about leading dots
const cleaned = validAssociations.map((assoc) => ({
...assoc,
ext: assoc.ext.map((ext) => {
const clean = ext.replace(/^\./, "");
if (clean !== ext) {
console.log(
`WARNING: fileAssociations ext "${ext}" has a leading dot — stripping to "${clean}"`,
);
}
return clean;
}),
}));

// Generate CFBundleDocumentTypes with LSItemContentTypes
const docTypes = cleaned
.map((assoc) => {
const role = assoc.role || "Viewer";
// Resolve icon: only reference if file exists to avoid dangling plist entries
let iconName = "";
if (assoc.icon) {
const iconSourcePath = join(projectRoot, assoc.icon);
if (existsSync(iconSourcePath)) {
iconName = basename(assoc.icon).replace(/\.icns$/i, "");
} else {
console.log(
`WARNING: Document type icon not found: ${iconSourcePath} — skipping icon reference`,
);
}
}
const iconLine = iconName
? ` <key>CFBundleTypeIconFile</key>\n <string>${escapeXml(iconName)}</string>\n`
: "";
// One UTI per extension, all listed under LSItemContentTypes
const utiXml = assoc.ext
.map(
(ext) =>
` <string>${escapeXml(appIdentifier)}.${escapeXml(ext)}</string>`,
)
.join("\n");
const extsXml = assoc.ext
.map(
(ext) =>
` <string>${escapeXml(ext)}</string>`,
)
.join("\n");

return ` <dict>
<key>CFBundleTypeName</key>
<string>${escapeXml(assoc.name)}</string>
<key>CFBundleTypeRole</key>
<string>${escapeXml(role)}</string>
${iconLine} <key>LSItemContentTypes</key>
<array>
${utiXml}
</array>
<key>CFBundleTypeExtensions</key>
<array>
${extsXml}
</array>
</dict>`;
})
.join("\n");

// Generate UTExportedTypeDeclarations — one per extension
const utiDecls = cleaned
.flatMap((assoc) => {
let iconName = "";
if (assoc.icon) {
const iconSourcePath = join(projectRoot, assoc.icon);
if (existsSync(iconSourcePath)) {
iconName = basename(assoc.icon).replace(/\.icns$/i, "");
}
}
const iconLine = iconName
? ` <key>UTTypeIconFiles</key>
<array>
<string>${escapeXml(iconName)}</string>
</array>\n`
: "";
return assoc.ext.map(
(ext) => ` <dict>
<key>UTTypeIdentifier</key>
<string>${escapeXml(appIdentifier)}.${escapeXml(ext)}</string>
<key>UTTypeDescription</key>
<string>${escapeXml(assoc.name)}</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
${iconLine} <key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>${escapeXml(ext)}</string>
</array>
</dict>
</dict>`,
);
})
.join("\n");

return ` <key>CFBundleDocumentTypes</key>
<array>
${docTypes}
</array>
<key>UTExportedTypeDeclarations</key>
<array>
${utiDecls}
</array>`;
}

// Execute command handling
(async () => {
if (commandArg === "init") {
Expand Down Expand Up @@ -2212,6 +2365,26 @@ Categories=Utility;Application;
cpSync(iconPath, targetIconPath, { dereference: true });
}
}

// Copy document type icon files to the app bundle Resources folder
if (targetOS === "macos" && config.app.fileAssociations) {
for (const assoc of config.app.fileAssociations) {
if (assoc.icon) {
const iconSourcePath = join(projectRoot, assoc.icon);
if (existsSync(iconSourcePath)) {
const iconFileName = basename(iconSourcePath);
const iconDestPath = join(
appBundleFolderResourcesPath,
iconFileName,
);
cpSync(iconSourcePath, iconDestPath, {
dereference: true,
});
}
// Missing icon warning is handled by generateDocumentTypes
}
}
}
};

// Run preBuild hook before anything starts
Expand Down Expand Up @@ -2287,6 +2460,12 @@ Categories=Utility;Application;
config.app.urlSchemes,
config.app.identifier,
);
// Generate document type associations
const documentTypes = generateDocumentTypes(
config.app.fileAssociations,
projectRoot,
config.app.identifier,
);

// When using .icon format, CFBundleIconName is needed for Assets.car lookup
const iconName = config.build.mac?.icons?.endsWith(".icon")
Expand All @@ -2309,7 +2488,9 @@ Categories=Utility;Application;
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>${iconName ? `\n <key>CFBundleIconName</key>\n <string>${iconName}</string>` : ""}${usageDescriptions ? "\n" + usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}
<string>AppIcon</string>${iconName ? `\n <key>CFBundleIconName</key>\n <string>${iconName}</string>` : ""}${usageDescriptions ? "\n" +
usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
"\n" + documentTypes : ""}
</dict>
</plist>`;

Expand Down
24 changes: 21 additions & 3 deletions package/src/native/macos/nativeWrapper.mm
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,12 @@ static void applyWindowButtonPosition(NSWindow *window, double x, double y) {
typedef SnapshotCallback zigSnapshotCallback;
typedef StatusItemHandler ZigStatusItemHandler;
static URLOpenHandler g_urlOpenHandler = nullptr;
// Buffer for URLs received before the handler is registered (cold-launch race).
// The NSApp delegate fires on the main thread as soon as the event loop starts,
// but the Bun Worker thread may not have registered its handler yet.
// NOTE: This buffering fixes a pre-existing race in URL handling (not just file handling).
static std::vector<std::string> g_pendingUrlOpenPaths;
static std::mutex g_urlOpenMutex;
static AppReopenHandler g_appReopenHandler = nullptr;
static QuitRequestedHandler g_quitRequestedHandler = nullptr;
static std::atomic<bool> g_shutdownComplete{false};
Expand Down Expand Up @@ -6399,11 +6405,13 @@ - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sende

// Handle URLs opened via custom URL schemes (deep linking)
- (void)application:(NSApplication *)application openURLs:(NSArray<NSURL *> *)urls {
std::lock_guard<std::mutex> lock(g_urlOpenMutex);
for (NSURL *url in urls) {
if (g_urlOpenHandler) {
g_urlOpenHandler([[url absoluteString] UTF8String]);
} else {
NSLog(@"[URL Handler] Received URL but no handler registered: %@", url);
// Buffer the URL — the Bun Worker hasn't registered its handler yet.
g_pendingUrlOpenPaths.push_back(std::string([[url absoluteString] UTF8String]));
}
}
}
Expand Down Expand Up @@ -7961,9 +7969,19 @@ static void showNotificationLegacy(NSString *titleStr, NSString *bodyStr, NSStri
// URL Scheme / Deep Linking API
// ============================================================================

// setURLOpenHandler - Set the callback for handling URLs opened via custom URL schemes
// setURLOpenHandler - Set the callback for handling URLs opened via custom URL schemes.
// Flushes any URLs that arrived before the handler was registered (cold-launch).
extern "C" void setURLOpenHandler(URLOpenHandler handler) {
g_urlOpenHandler = handler;
std::vector<std::string> pending;
{
std::lock_guard<std::mutex> lock(g_urlOpenMutex);
g_urlOpenHandler = handler;
pending = std::move(g_pendingUrlOpenPaths);
}
// Deliver outside the lock to avoid holding it during the FFI call into Bun
for (const auto& url : pending) {
handler(url.c_str());
}
}

extern "C" void setAppReopenHandler(AppReopenHandler handler) {
Expand Down