@witchcraft/nuxt-electron 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +299 -0
  2. package/dist/module.d.mts +117 -0
  3. package/dist/module.json +9 -0
  4. package/dist/module.mjs +362 -0
  5. package/dist/runtime/components/ElectronWindowControls.d.vue.ts +31 -0
  6. package/dist/runtime/components/ElectronWindowControls.vue +67 -0
  7. package/dist/runtime/components/ElectronWindowControls.vue.d.ts +31 -0
  8. package/dist/runtime/components/WindowControls/CloseButton.d.vue.ts +16 -0
  9. package/dist/runtime/components/WindowControls/CloseButton.vue +54 -0
  10. package/dist/runtime/components/WindowControls/CloseButton.vue.d.ts +16 -0
  11. package/dist/runtime/components/WindowControls/MaximizeButton.d.vue.ts +16 -0
  12. package/dist/runtime/components/WindowControls/MaximizeButton.vue +33 -0
  13. package/dist/runtime/components/WindowControls/MaximizeButton.vue.d.ts +16 -0
  14. package/dist/runtime/components/WindowControls/MinimizeButton.d.vue.ts +16 -0
  15. package/dist/runtime/components/WindowControls/MinimizeButton.vue +40 -0
  16. package/dist/runtime/components/WindowControls/MinimizeButton.vue.d.ts +16 -0
  17. package/dist/runtime/components/WindowControls/PinButton.d.vue.ts +27 -0
  18. package/dist/runtime/components/WindowControls/PinButton.vue +51 -0
  19. package/dist/runtime/components/WindowControls/PinButton.vue.d.ts +27 -0
  20. package/dist/runtime/electron/apiBuilder.d.ts +8 -0
  21. package/dist/runtime/electron/apiBuilder.js +9 -0
  22. package/dist/runtime/electron/createBroadcastHandlers.d.ts +4 -0
  23. package/dist/runtime/electron/createBroadcastHandlers.js +30 -0
  24. package/dist/runtime/electron/createBroadcaster.d.ts +2 -0
  25. package/dist/runtime/electron/createBroadcaster.js +7 -0
  26. package/dist/runtime/electron/createNuxtFileProtocolHandler.d.ts +14 -0
  27. package/dist/runtime/electron/createNuxtFileProtocolHandler.js +20 -0
  28. package/dist/runtime/electron/createWindowControlsApi.d.ts +19 -0
  29. package/dist/runtime/electron/createWindowControlsApi.js +6 -0
  30. package/dist/runtime/electron/createWindowControlsApiHandler.d.ts +4 -0
  31. package/dist/runtime/electron/createWindowControlsApiHandler.js +24 -0
  32. package/dist/runtime/electron/getEventWindow.d.ts +11 -0
  33. package/dist/runtime/electron/getEventWindow.js +8 -0
  34. package/dist/runtime/electron/getPaths.d.ts +27 -0
  35. package/dist/runtime/electron/getPaths.js +33 -0
  36. package/dist/runtime/electron/getPreloadMeta.d.ts +25 -0
  37. package/dist/runtime/electron/getPreloadMeta.js +7 -0
  38. package/dist/runtime/electron/index.d.ts +16 -0
  39. package/dist/runtime/electron/index.js +16 -0
  40. package/dist/runtime/electron/promisifyApi.d.ts +40 -0
  41. package/dist/runtime/electron/promisifyApi.js +41 -0
  42. package/dist/runtime/electron/promisifyReply.d.ts +8 -0
  43. package/dist/runtime/electron/promisifyReply.js +27 -0
  44. package/dist/runtime/electron/registerDevtoolsShortcuts.d.ts +2 -0
  45. package/dist/runtime/electron/registerDevtoolsShortcuts.js +10 -0
  46. package/dist/runtime/electron/static.d.ts +12 -0
  47. package/dist/runtime/electron/static.js +8 -0
  48. package/dist/runtime/electron/types.d.ts +1 -0
  49. package/dist/runtime/electron/types.js +0 -0
  50. package/dist/runtime/electron/useDevDataDir.d.ts +1 -0
  51. package/dist/runtime/electron/useDevDataDir.js +8 -0
  52. package/dist/runtime/electron/useNuxtRuntimeConfig.d.ts +2 -0
  53. package/dist/runtime/electron/useNuxtRuntimeConfig.js +4 -0
  54. package/dist/runtime/utils/isElectron.d.ts +9 -0
  55. package/dist/runtime/utils/isElectron.js +3 -0
  56. package/dist/types.d.mts +3 -0
  57. package/genDevDesktop.js +47 -0
  58. package/package.json +93 -0
  59. package/src/module.ts +549 -0
  60. package/src/runtime/components/ElectronWindowControls.vue +94 -0
  61. package/src/runtime/components/WindowControls/CloseButton.vue +56 -0
  62. package/src/runtime/components/WindowControls/MaximizeButton.vue +35 -0
  63. package/src/runtime/components/WindowControls/MinimizeButton.vue +42 -0
  64. package/src/runtime/components/WindowControls/PinButton.vue +56 -0
  65. package/src/runtime/electron/apiBuilder.ts +27 -0
  66. package/src/runtime/electron/createBroadcastHandlers.ts +36 -0
  67. package/src/runtime/electron/createBroadcaster.ts +11 -0
  68. package/src/runtime/electron/createNuxtFileProtocolHandler.ts +42 -0
  69. package/src/runtime/electron/createWindowControlsApi.ts +27 -0
  70. package/src/runtime/electron/createWindowControlsApiHandler.ts +34 -0
  71. package/src/runtime/electron/getEventWindow.ts +19 -0
  72. package/src/runtime/electron/getPaths.ts +68 -0
  73. package/src/runtime/electron/getPreloadMeta.ts +36 -0
  74. package/src/runtime/electron/index.ts +17 -0
  75. package/src/runtime/electron/promisifyApi.ts +102 -0
  76. package/src/runtime/electron/promisifyReply.ts +49 -0
  77. package/src/runtime/electron/registerDevtoolsShortcuts.ts +12 -0
  78. package/src/runtime/electron/static.ts +14 -0
  79. package/src/runtime/electron/types.ts +1 -0
  80. package/src/runtime/electron/useDevDataDir.ts +8 -0
  81. package/src/runtime/electron/useNuxtRuntimeConfig.ts +7 -0
  82. package/src/runtime/utils/isElectron.ts +11 -0
@@ -0,0 +1,35 @@
1
+ <template>
2
+ <WButton
3
+ :border="false"
4
+ aria-label="Toggle Maximize"
5
+ class="
6
+ p-0
7
+ [&:hover_.default-icon]:border-accent-500
8
+ [&:hover_.default-icon]:shadow-xs
9
+ [&:hover_.default-icon]:shadow-fg/50
10
+
11
+ "
12
+ @click="emit('action', 'toggleMaximize')"
13
+ >
14
+ <slot>
15
+ <div
16
+ class="
17
+ default-icon
18
+ border-fg
19
+ dark:border-bg
20
+ hover:border-accent-500
21
+ border-[length:var(--electron-wc-border)]
22
+ rounded-(--electron-wc-rounded)
23
+ w-[var(--electron-wc-size)]
24
+ h-[var(--electron-wc-size)]
25
+ "
26
+ />
27
+ </slot>
28
+ </WButton>
29
+ </template>
30
+
31
+ <script lang="ts" setup>
32
+ const emit = defineEmits<{
33
+ (e: "action", action: "toggleMaximize"): void
34
+ }>()
35
+ </script>
@@ -0,0 +1,42 @@
1
+ <template>
2
+ <WButton
3
+ :border="false"
4
+ aria-label="Minimize"
5
+ class="
6
+ p-0
7
+ [&:hover_.default-icon:after]:bg-accent-500
8
+ [&:hover_.default-icon:after]:shadow-xs
9
+ [&:hover_.default-icon:after]:shadow-fg/50
10
+ "
11
+ @click="emit('action', 'minimize')"
12
+ >
13
+ <slot>
14
+ <div
15
+ class="
16
+ default-icon
17
+ border-fg
18
+ dark:border-bg
19
+ w-[var(--electron-wc-size)]
20
+ h-[var(--electron-wc-size)]
21
+ relative
22
+ after:absolute
23
+ after:content-['']
24
+ after:bottom-0
25
+ after:left-0
26
+ after:w-[var(--electron-wc-size)]
27
+ after:h-[var(--electron-wc-border)]
28
+ after:rounded-(--electron-wc-rounded)
29
+ after:bg-fg
30
+ dark:after:bg-bg
31
+
32
+ "
33
+ />
34
+ </slot>
35
+ </WButton>
36
+ </template>
37
+
38
+ <script lang="ts" setup>
39
+ const emit = defineEmits<{
40
+ (e: "action", action: "minimize"): void
41
+ }>()
42
+ </script>
@@ -0,0 +1,56 @@
1
+ <template>
2
+ <WButton
3
+ aria-label="Toggle Always On Top"
4
+ auto-title-from-aria
5
+ :border="false"
6
+ :class="twMerge(`
7
+ p-0
8
+ hover:text-accent-500
9
+ `,
10
+ alwaysOnTop && `
11
+ [&_.default-icon_svg]:drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]
12
+ `,
13
+ !alwaysOnTop && `
14
+ text-neutral-400
15
+ dark:text-neutral-600
16
+ `
17
+ )"
18
+
19
+ @click="emit('action', 'togglePin')"
20
+ >
21
+ <slot v-bind="{ alwaysOnTop }">
22
+ <WIcon
23
+ :class="twMerge(
24
+ `
25
+ default-icon
26
+ w-[var(--electron-wc-size)]
27
+ h-[var(--electron-wc-size)]
28
+ flex items-center justify-center
29
+ scale-105
30
+ `
31
+ )
32
+ "
33
+ >
34
+ <i-octicon-pin-16/>
35
+ </WIcon>
36
+ </slot>
37
+ </WButton>
38
+ </template>
39
+
40
+ <script lang="ts" setup>
41
+ import { twMerge } from "#imports"
42
+
43
+ const emit = defineEmits<{
44
+ (e: "action", action: "togglePin"): void
45
+ }>()
46
+ /**
47
+ * If there is a `window.electron.on` api that allows listening to events and main sends an `always-on-top-changed`, it will update the state of the pin.
48
+ */
49
+ const alwaysOnTop = defineModel<boolean>({ default: false })
50
+
51
+ if (import.meta.client && (window as any)?.electron?.on) {
52
+ (window as any).electron.on("always-on-top-changed", (val: boolean) => {
53
+ alwaysOnTop.value = val
54
+ })
55
+ }
56
+ </script>
@@ -0,0 +1,27 @@
1
+ import type { DeepPartial } from "@alanscodelog/utils"
2
+ import defu from "defu"
3
+ import type { IpcRenderer } from "electron"
4
+ /**
5
+ * Helper for building an api object.
6
+ *
7
+ * @experimental
8
+ */
9
+ export function apiBuilder<
10
+ TPreloadMeta extends Record<string, any>,
11
+ TRawApi extends Record<string, any> = Record<string, any>,
12
+ TBuilders extends
13
+ ((meta: DeepPartial<TPreloadMeta>, ipcRenderer: IpcRenderer) => any)[] = ((meta: DeepPartial<TPreloadMeta>, ipcRenderer: IpcRenderer) => any)[],
14
+ TBuilderOutput extends ReturnType<TBuilders[number]> = ReturnType<TBuilders[number]>
15
+ >(
16
+ ipcRenderer: IpcRenderer,
17
+ api: TRawApi,
18
+ meta: TPreloadMeta,
19
+ builders: TBuilders
20
+ ): TRawApi & TBuilderOutput {
21
+ const resolutions: object[] = []
22
+ resolutions.push(api)
23
+ for (const builder of builders) {
24
+ resolutions.push(builder(meta, ipcRenderer))
25
+ }
26
+ return (defu as any)(...resolutions.reverse()) as any
27
+ }
@@ -0,0 +1,36 @@
1
+ import { ipcRenderer } from "electron"
2
+
3
+ export function createBroadcastHandlers<TEvents extends Record<string, (...args: any) => any>>(
4
+ key: string
5
+ ): {
6
+ on: <T extends keyof TEvents>(event: T, listener: TEvents[T]) => void
7
+ off: <T extends keyof TEvents>(event: T, listener: TEvents[T]) => void
8
+ } {
9
+ const listeners = new Map<keyof TEvents, (TEvents[keyof TEvents])[]>()
10
+ ipcRenderer.on(key, (_e, eventName, ...args: any[]) => {
11
+ const cbs = listeners.get(eventName)
12
+ if (cbs) {
13
+ for (const cb of cbs) {
14
+ cb(...args)
15
+ }
16
+ }
17
+ })
18
+ function on<T extends keyof TEvents>(event: T, listener: TEvents[T]): void {
19
+ const cbs = listeners.get(event)
20
+ if (cbs) {
21
+ cbs.push(listener)
22
+ } else {
23
+ listeners.set(event, [listener])
24
+ }
25
+ }
26
+
27
+ function off<T extends keyof TEvents>(event: T, listener: TEvents[T]): void {
28
+ const cbs = listeners.get(event)
29
+ if (cbs) {
30
+ cbs.splice(cbs.indexOf(listener), 1)
31
+ }
32
+ }
33
+ return {
34
+ on, off
35
+ }
36
+ }
@@ -0,0 +1,11 @@
1
+ import type { BrowserWindow } from "electron"
2
+
3
+ export function createBroadcaster<
4
+ TEvents extends Record<string, (...args: any) => any>
5
+ >(key: string, getWindows: () => BrowserWindow[]) {
6
+ return function broadcast<T extends keyof TEvents>(event: T, ...args: Parameters<TEvents[T]>) {
7
+ for (const win of getWindows()) {
8
+ win.webContents.send(key, event, ...args)
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,42 @@
1
+ import { keys } from "@alanscodelog/utils/keys"
2
+ import { net } from "electron"
3
+ import path from "node:path"
4
+
5
+ export function createNuxtFileProtocolHandler(
6
+ session: Electron.Session,
7
+ basePath: string,
8
+ /**
9
+ * If any route starts with one of these keys, it will get rerouted to the given value.
10
+ *
11
+ * At least `/api` should be added for a basic nuxt app to work correctly.
12
+ *
13
+ *
14
+ * ```ts
15
+ * routeProxies: {
16
+ * "/api": "http://localhost:3000/api",
17
+ * }
18
+ * ```
19
+ */
20
+ routeProxies: Record<string, string>
21
+ ): void {
22
+ const routeProxyKeys = keys(routeProxies)
23
+ session.protocol.handle("file", async request => {
24
+ const url = decodeURIComponent(request.url.slice(7))
25
+ const newUrl = path.isAbsolute(url)
26
+ ? path.resolve(basePath, url.slice(1))
27
+ : path.resolve(basePath, url)
28
+
29
+ const proxyKey = routeProxyKeys.find(key => url.startsWith(key))
30
+
31
+ if (proxyKey) {
32
+ const proxyUrl = routeProxies[proxyKey]
33
+ return net.fetch(proxyUrl + url)
34
+ }
35
+
36
+ const res = await net.fetch(`file://${newUrl}`, {
37
+ // avoid infinite loop
38
+ bypassCustomProtocolHandlers: true
39
+ }).catch(err => new Response(err, { status: 404 }))
40
+ return res
41
+ })
42
+ }
@@ -0,0 +1,27 @@
1
+ import type { IpcRenderer } from "electron"
2
+
3
+ import { promisifyApi } from "./promisifyApi.js"
4
+ import type { WindowControlsApi } from "./types.js"
5
+
6
+ const key = "window-control-action"
7
+ /**
8
+ * Creates the apis to control the window from the preload script / client.
9
+ *
10
+ * ```ts
11
+ * // the default handler expects it to exist under `electron.api.ui.windowAction`
12
+ * // but you can configure it to use a different path
13
+ * constextBridge.exposeInMainWorld("electron", {
14
+ * api: {
15
+ * ui: {
16
+ * ...createWindowControlsApi("windowAction")
17
+ * }
18
+ * }
19
+ * })
20
+ * ```
21
+ */
22
+ export function createWindowControlsApi(ipcRenderer: IpcRenderer, name: string): Record<string, WindowControlsApi> {
23
+ return promisifyApi<typeof name,
24
+ WindowControlsApi
25
+ >(ipcRenderer, name, key)
26
+ }
27
+ export const windowControlsMessageKey = key
@@ -0,0 +1,34 @@
1
+ import type { BrowserWindow } from "electron"
2
+
3
+ import { windowControlsMessageKey } from "./createWindowControlsApi.js"
4
+ import { promisifyReply } from "./promisifyReply.js"
5
+ import type { WindowControlsApi } from "./types.js"
6
+
7
+ export function createWindowControlsApiHandler(
8
+ /** Mostly for logging. Does not replace the action. */
9
+ cb?: (win: BrowserWindow | undefined, action: "close" | "minimize" | "toggleMaximize" | "togglePin") => void
10
+ ) {
11
+ promisifyReply<
12
+ WindowControlsApi
13
+ >(windowControlsMessageKey, async (win, action) => {
14
+ switch (action) {
15
+ case "close":
16
+ win?.close()
17
+ break
18
+ case "minimize":
19
+ win?.minimize()
20
+ break
21
+ case "toggleMaximize":
22
+ // eslint-disable-next-line no-console
23
+ console.log(win?.isMaximized())
24
+ win?.isMaximized() ? win?.unmaximize() : win?.maximize()
25
+ break
26
+ case "togglePin":
27
+ win?.setAlwaysOnTop(!win?.isAlwaysOnTop())
28
+ break
29
+ default:
30
+ throw new Error(`Invalid action: ${action}`)
31
+ }
32
+ cb?.(win, action)
33
+ })
34
+ }
@@ -0,0 +1,19 @@
1
+ import { BrowserWindow } from "electron"
2
+
3
+ /**
4
+ * Gets the window that sent the event if it exists, otherwise tries to use the focused window.
5
+ *
6
+ * Set `defaultToFocused` to false to only use the sender window.
7
+ *
8
+ * Returns undefined if no window is found.
9
+ */
10
+ export function getEventWindow(
11
+ e: Electron.IpcMainEvent,
12
+ { defaultToFocused = true } = {}
13
+ ): BrowserWindow | undefined {
14
+ const senderWindow = BrowserWindow.fromId(e.sender.id)
15
+ if (defaultToFocused) {
16
+ return senderWindow ?? BrowserWindow.getFocusedWindow() ?? undefined
17
+ }
18
+ return senderWindow ?? undefined
19
+ }
@@ -0,0 +1,68 @@
1
+ import { unreachable } from "@alanscodelog/utils/unreachable"
2
+ import { app } from "electron"
3
+ import path from "node:path"
4
+
5
+ import { STATIC } from "./static.js"
6
+
7
+ export function forceRelativePath(filepath: string): string {
8
+ return path.join(`.${path.sep}`, filepath)
9
+ }
10
+
11
+ /**
12
+ * Calculates the correct paths for the app to run in electron.
13
+ *
14
+ * Note: For serverUrl to not be overridable in production, you should set `electron.additionalElectronVariables.publicServerUrl` to a quoted string.
15
+ *
16
+ * ```ts[nuxt.config.ts]
17
+ * export default defineNuxtConfig({
18
+ * modules: [
19
+ * "@witchcraft/nuxt-electron",
20
+ * ],
21
+ * electron: {
22
+ * additionalElectronVariables: {
23
+ * publicServerUrl: process.env.NODE_ENV === "production"
24
+ * ? `"mysite.com"`
25
+ * : `undefined`
26
+ * }
27
+ * }
28
+ * })
29
+ * ```
30
+ */
31
+ export function getPaths(): {
32
+ windowUrl: string
33
+ publicServerUrl: string
34
+ nuxtPublicDir: string
35
+ preloadPath: string
36
+ } {
37
+ const rootDir = app.getAppPath()
38
+ const nuxtPublicDir = path.resolve(rootDir, forceRelativePath(STATIC.ELECTRON_NUXT_PUBLIC_DIR!))
39
+
40
+ const preloadPath = path.resolve(rootDir, forceRelativePath(STATIC.ELECTRON_BUILD_DIR!), "./preload.cjs")
41
+
42
+ const base = {
43
+ nuxtPublicDir,
44
+ preloadPath,
45
+ publicServerUrl: (process.env.PUBLIC_SERVER_URL
46
+ // allows us to override it when previewing (VITE_DEV_URL is not available then)
47
+ ?? process.env.PUBLIC_SERVER_URL
48
+ ?? process.env.VITE_DEV_SERVER_URL)!
49
+ }
50
+ if (!base.publicServerUrl) {
51
+ throw new Error("publicServerUrl could not be determined.")
52
+ }
53
+
54
+ if (process.env.VITE_DEV_SERVER_URL) {
55
+ return {
56
+ ...base,
57
+ windowUrl: `${process.env.VITE_DEV_SERVER_URL}${STATIC.ELECTRON_ROUTE}`
58
+ }
59
+ // this will always be defined in production since they are defined by vite
60
+ } else if (STATIC.ELECTRON_PROD_URL && STATIC.ELECTRON_BUILD_DIR) {
61
+ return {
62
+ ...base,
63
+ // careful, do not use path.join, it will remove extra slashes
64
+ windowUrl: `file://${STATIC.ELECTRON_PROD_URL}`
65
+ }
66
+ }
67
+ unreachable()
68
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The preload script can be passed arguments using the `webPreferences.additionalArguments` option.
3
+ *
4
+ * ````ts [main.ts]
5
+ * const meta = { ... }
6
+ *
7
+ * const win = new BrowserWindow({
8
+ * webPreferences: {
9
+ * additionalArguments: [
10
+ * // you can change which key is used
11
+ * `--metadata=${JSON.stringify(meta)}`,
12
+ * ],
13
+ * },
14
+ * })
15
+ * ```
16
+ *
17
+ * ````ts [preload.ts]
18
+ * const meta = getPreloadMeta<Meta>("metadata")
19
+ * ```
20
+ *
21
+ * This function can be used to get the metadata and type it in the preload script.
22
+ *
23
+ * Note that if it can't find the argument or there is an error parsing the json, it will throw.
24
+ */
25
+ export function getPreloadMeta<T>(
26
+ key: string
27
+ ): T {
28
+ const meta = process.argv
29
+ .find(arg => arg.startsWith(`--${key}=`))
30
+ ?.split("=")?.[1]
31
+
32
+ if (!meta) {
33
+ throw new Error("No metadata passed to renderer.")
34
+ }
35
+ return JSON.parse(meta)
36
+ }
@@ -0,0 +1,17 @@
1
+ export * from "./types"
2
+ // note adding file endings breaks build types
3
+ export { getPaths } from "./getPaths"
4
+ export { createNuxtFileProtocolHandler } from "./createNuxtFileProtocolHandler"
5
+ export { createWindowControlsApi } from "./createWindowControlsApi"
6
+ export { createWindowControlsApiHandler } from "./createWindowControlsApiHandler"
7
+ export { useNuxtRuntimeConfig } from "./useNuxtRuntimeConfig"
8
+ export { STATIC } from "./static"
9
+ export { apiBuilder } from "./apiBuilder"
10
+ export { useDevDataDir } from "./useDevDataDir"
11
+ export { registerDevtoolsShortcuts } from "./registerDevtoolsShortcuts"
12
+ export { promisifyApi } from "./promisifyApi"
13
+ export { getPreloadMeta } from "./getPreloadMeta"
14
+ export { getEventWindow } from "./getEventWindow"
15
+ export { promisifyReply } from "./promisifyReply"
16
+ export { createBroadcaster } from "./createBroadcaster"
17
+ export { createBroadcastHandlers } from "./createBroadcastHandlers"
@@ -0,0 +1,102 @@
1
+ import type { IpcRenderer } from "electron"
2
+
3
+ const promiseResolveMap = new Map<string, {
4
+ resolve: (value: any) => void
5
+ reject: (reason?: any) => void
6
+ }>()
7
+
8
+ /**
9
+ * Promisify an electron api and make it type safe so it can be awaited client side. Note that promisifyReply will throw if it can't find a window.
10
+ *
11
+ * ```ts[type.st]
12
+ * export type ElectronApi = {
13
+ * api: {
14
+ * someApi: (apiParam: string, apiParam2: number) => Promise<void>
15
+ * }
16
+ * }
17
+ * ```
18
+ * ```ts [preload.ts]
19
+ * contextBridge.exposeInMainWorld("electron", {
20
+ * api: {
21
+ * ...promisifyApi<"someApi", ElectronApi["api"]["someApi"]>(ipcRenderer, "someApi", MESSAGE.UNIQUE_KEY),
22
+ * },
23
+ * })
24
+ * ```
25
+ *
26
+ * ```ts [main.ts]
27
+ * import { promisifyReply } from "@witchcraft/nuxt-electron/runtime/electron"
28
+ * promisifyReply<
29
+ * ElectronApi["api"]["someApi"]
30
+ * >(ipcRenderer, MESSAGE.UNIQUE_KEY, (win, apiParam, apiParam2) => {
31
+ * // this doesn't have to be async, though on the client side,
32
+ * // it will always be async
33
+ * return electronSideOfApi(apiParam, apiParam2)
34
+ * })
35
+ * ```
36
+ *
37
+ * ```ts [renderer.ts]
38
+ * const result = await electron.api.someApi(apiParam, apiParam2)
39
+ * ```
40
+ *
41
+ * By default, calls will timeout and reject after 10 seconds. This can be changed by changing the timeout option.
42
+ */
43
+ export function promisifyApi<
44
+ TKey extends string,
45
+ TFunction extends (...args: any) => Promise<any>,
46
+ TArgs extends Parameters<TFunction> = Parameters<TFunction>
47
+ >(
48
+ ipcRenderer: IpcRenderer,
49
+ key: TKey,
50
+ messageKey: string,
51
+ modifyArgs?: (args: TArgs) => any,
52
+ { debug, timeout }: { debug?: boolean, timeout?: number } = { debug: true, timeout: 10000 }
53
+ ): Record<TKey, TFunction> {
54
+ return {
55
+ [key]: async (...args: TArgs) => new Promise((resolve, reject) => {
56
+ // preload script should have access to crypto
57
+ // can't seem to get node crypto to import even renamed
58
+ const promiseId = crypto.randomUUID()
59
+ promiseResolveMap.set(promiseId, { resolve, reject })
60
+ if (modifyArgs) {
61
+ args = modifyArgs(args)
62
+ }
63
+ if (debug) {
64
+ // eslint-disable-next-line no-console
65
+ console.log("promisifyApi:sent", messageKey, promiseId, args)
66
+ }
67
+
68
+ const timer: number | NodeJS.Timeout = setTimeout(() => {
69
+ const resolver = promiseResolveMap.get(promiseId)
70
+ if (resolver) {
71
+ promiseResolveMap.delete(promiseId)
72
+ ipcRenderer.off(messageKey, listener)
73
+ resolver.reject(new Error(`promisifyApi: Timeout for ${messageKey}`))
74
+ }
75
+ }, timeout)
76
+ function listener(
77
+ _event: Electron.IpcRendererEvent,
78
+ resPromiseId: string,
79
+ res: any,
80
+ info?: { isError?: boolean }
81
+ ): void {
82
+ if (debug) {
83
+ // eslint-disable-next-line no-console
84
+ console.log("promiseApi:received", resPromiseId, res)
85
+ }
86
+ const resolver = promiseResolveMap.get(resPromiseId)
87
+ if (resolver) {
88
+ clearTimeout(timer)
89
+ promiseResolveMap.delete(resPromiseId)
90
+ ipcRenderer.off(messageKey, listener)
91
+ if (info?.isError) {
92
+ resolver.reject(res)
93
+ } else {
94
+ resolver.resolve(res)
95
+ }
96
+ }
97
+ }
98
+ ipcRenderer.on(messageKey, listener)
99
+ ipcRenderer.send(messageKey, promiseId, ...args)
100
+ })
101
+ } as any
102
+ }
@@ -0,0 +1,49 @@
1
+ import { type BrowserWindow, ipcMain } from "electron"
2
+
3
+ import { getEventWindow } from "./getEventWindow.js"
4
+
5
+ /** See {@link promisifyApi} for more info. */
6
+ export function promisifyReply<
7
+ TFunction extends ((...args: any[]) => any),
8
+ TKey extends string = string,
9
+ TArgs extends Parameters<TFunction> = Parameters<TFunction>,
10
+ TReturn extends ReturnType<TFunction> = ReturnType<TFunction>
11
+ >(
12
+ key: TKey,
13
+ cb: (win?: BrowserWindow, ...args: TArgs) => Promise<TReturn> | TReturn,
14
+ /** See {@link getEventWindow}. */
15
+ {
16
+ defaultToFocused = true,
17
+ debug = false
18
+ }: {
19
+ defaultToFocused?: boolean
20
+ debug?: boolean
21
+ } = {}
22
+ ): void {
23
+ ipcMain.on(key, (
24
+ e,
25
+ promiseId: string,
26
+ ...args: any[]
27
+ ) => {
28
+ const win = getEventWindow(e, { defaultToFocused })
29
+ if (!win) {
30
+ throw new Error("No window to send reply to.")
31
+ }
32
+ if (debug) {
33
+ // eslint-disable-next-line no-console
34
+ console.log({ key, promiseId, args })
35
+ }
36
+ const res = cb(win, ...args as TArgs)
37
+ if (res instanceof Promise) {
38
+ res.then((innerRes: any) => {
39
+ win.webContents.send(key, promiseId, innerRes)
40
+ }).catch(err => {
41
+ // eslint-disable-next-line no-console
42
+ console.error(err, { key, promiseId, args })
43
+ win.webContents.send(key, promiseId, err, { isError: true })
44
+ })
45
+ } else {
46
+ win.webContents.send(key, promiseId, res)
47
+ }
48
+ })
49
+ }
@@ -0,0 +1,12 @@
1
+ /** Registers F12 and Ctrl+Shift+I as shortcuts to open the devtools. */
2
+ export function registerDevtoolsShortcuts(win: Electron.BrowserWindow): void {
3
+ win.webContents.on("before-input-event", (event, input) => {
4
+ if (input.type === "keyDown") {
5
+ if ((input.shift && input.control && input.key.toLowerCase() === "i")
6
+ || (input.key === "F12")) {
7
+ win.webContents.openDevTools()
8
+ event.preventDefault()
9
+ }
10
+ }
11
+ })
12
+ }
@@ -0,0 +1,14 @@
1
+ import type { PublicRuntimeConfig } from "nuxt/schema"
2
+
3
+ // Note that we must write them out like this (with the full path to the env variable) for vite to find them.
4
+ /**
5
+ * Group of the module's vite's injected variables.
6
+ */
7
+ export const STATIC = {
8
+ ELECTRON_ROUTE: process.env.ELECTRON_ROUTE!,
9
+ ELECTRON_PROD_URL: process.env.ELECTRON_PROD_URL!,
10
+ ELECTRON_NUXT_DIR: process.env.ELECTRON_NUXT_DIR!,
11
+ ELECTRON_NUXT_PUBLIC_DIR: process.env.ELECTRON_NUXT_PUBLIC_DIR!,
12
+ ELECTRON_BUILD_DIR: process.env.ELECTRON_BUILD_DIR!,
13
+ ELECTRON_RUNTIME_CONFIG: (process.env.ELECTRON_RUNTIME_CONFIG ? JSON.parse(process.env.ELECTRON_RUNTIME_CONFIG) : undefined) as PublicRuntimeConfig
14
+ }
@@ -0,0 +1 @@
1
+ export type WindowControlsApi = (action: "close" | "minimize" | "toggleMaximize" | "togglePin") => Promise<void>
@@ -0,0 +1,8 @@
1
+ export function useDevDataDir(): string | undefined {
2
+ if (import.meta.dev) {
3
+ const index = process.argv.findIndex(arg => arg.startsWith("--user-data-dir")) + 1
4
+ if (index === -1) return undefined
5
+ return process.argv[index + 1]
6
+ }
7
+ return undefined
8
+ }
@@ -0,0 +1,7 @@
1
+ import type { PublicRuntimeConfig } from "nuxt/schema"
2
+
3
+ import { STATIC } from "./static.js"
4
+
5
+ export function useNuxtRuntimeConfig(): PublicRuntimeConfig {
6
+ return STATIC.ELECTRON_RUNTIME_CONFIG
7
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * This requires electron's preload script set the `electron` property on the window:
3
+ *
4
+ * ```ts
5
+ * // preload.ts
6
+ * contextBridge.exposeInMainWorld("electron", { })
7
+ * ```
8
+ */
9
+ export function isElectron(): boolean {
10
+ return typeof window !== "undefined" && "electron" in window && typeof window.electron !== "undefined"
11
+ }