diff --git a/.vscode/.debug.script.mjs b/.vscode/.debug.script.mjs index 2066b94..e3674cb 100644 --- a/.vscode/.debug.script.mjs +++ b/.vscode/.debug.script.mjs @@ -1,8 +1,8 @@ -import fs from 'fs' -import path from 'path' -import { fileURLToPath } from 'url' -import { createRequire } from 'module' -import { spawn } from 'child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createRequire } from 'node:module' +import { spawn } from 'node:child_process' const pkg = createRequire(import.meta.url)('../package.json') const __dirname = path.dirname(fileURLToPath(import.meta.url)) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index c4a5d2c..232ead7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,6 @@ { - "recommendations": ["vue.vscode-typescript-vue-plugin", "Vue.volar"] + "recommendations": [ + "Vue.volar", + "Vue.vscode-typescript-vue-plugin" + ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 9250453..3d01d65 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,7 +22,7 @@ "configurations": [ { "name": "Debug Main Process", - "type": "pwa-node", + "type": "node", "request": "launch", "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", "windows": { @@ -39,8 +39,15 @@ "name": "Debug Renderer Process", "port": 9229, "request": "attach", - "type": "pwa-chrome", - "timeout": 60000 + "type": "chrome", + "timeout": 60000, + "skipFiles": [ + "/**", + "${workspaceRoot}/node_modules/**", + "${workspaceRoot}/dist-electron/**", + // Skip files in host(VITE_DEV_SERVER_URL) + "http://127.0.0.1:3344/**" + ] }, ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 5a96bc9..1e3e2cd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,11 @@ { "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.tsc.autoDetect": "off", "json.schemas": [ { "fileMatch": [ - "/*electron-builder.json5" + "/*electron-builder.json5", + "/*electron-builder.json" ], "url": "https://json.schemastore.org/electron-builder" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3add5f7..adb5c58 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -22,7 +22,8 @@ }, "background": { "activeOnStart": true, - "endsPattern": "^.*[startup] Electron App.*$", + "beginsPattern": "^.*VITE v.* ready in \\d* ms.*$", + "endsPattern": "^.*\\[startup\\] Electron App.*$" } } } diff --git a/electron-builder.json5 b/electron-builder.json5 index 1bf967d..ec06d97 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -2,17 +2,33 @@ * @see https://www.electron.build/configuration/configuration */ { - appId: 'Vigad4256', + $schema: 'https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json', + appId: 'Vigad', asar: true, directories: { output: 'release/${version}', }, files: ['dist-electron', 'dist'], + linux: { + icon: 'public/icon.png', + target: ['AppImage'], + category: 'Utility', + executableName: 'vigad', + desktop: { + Name: 'Vigad', + Icon: 'public/icon.png', + Terminal: 'false', + Type: 'Application', + Categories: 'Utility;', + }, + }, mac: { + icon: 'public/icon.icns', artifactName: '${productName}_${version}.${ext}', target: ['dmg'], }, win: { + icon: 'public/icon.ico', target: [ { target: 'nsis', diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 3b33998..ae2ea26 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -1,11 +1,11 @@ /// declare namespace NodeJS { - interface ProcessEnv { - VSCODE_DEBUG?: 'true' - DIST_ELECTRON: string - DIST: string - /** /dist/ or /public/ */ - PUBLIC: string - } + interface ProcessEnv { + VSCODE_DEBUG?: 'true'; + DIST_ELECTRON: string; + DIST: string; + /** /dist/ or /public/ */ + PUBLIC: string; + } } diff --git a/electron/main/index.ts b/electron/main/index.ts index 52d4f68..8520fb6 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,3 +1,7 @@ +import { app, BrowserWindow, shell, ipcMain } from 'electron'; +import { release } from 'node:os'; +import { join } from 'node:path'; + // The built directory structure // // ├─┬ dist-electron @@ -10,13 +14,9 @@ // process.env.DIST_ELECTRON = join(__dirname, '..'); process.env.DIST = join(process.env.DIST_ELECTRON, '../dist'); -process.env.PUBLIC = app.isPackaged - ? process.env.DIST - : join(process.env.DIST_ELECTRON, '../public'); - -import { app, BrowserWindow, shell, ipcMain } from 'electron'; -import { release } from 'os'; -import { join } from 'path'; +process.env.PUBLIC = process.env.VITE_DEV_SERVER_URL + ? join(process.env.DIST_ELECTRON, '../public') + : process.env.DIST; // Disable GPU Acceleration for Windows 7 if (release().startsWith('6.1')) app.disableHardwareAcceleration(); @@ -41,13 +41,28 @@ const url = process.env.VITE_DEV_SERVER_URL; const indexHtml = join(process.env.DIST, 'index.html'); async function createWindow() { + // Determine the icon file based on the current platform + let iconFile; + switch (process.platform) { + case 'win32': + iconFile = 'icon.ico'; + break; + case 'darwin': + iconFile = 'icon.icns'; + break; + case 'linux': + iconFile = 'icon.png'; + break; + default: + iconFile = 'icon.png'; + } win = new BrowserWindow({ - title: 'Main window', + title: 'Vigad', + icon: join(process.env.PUBLIC, iconFile), minWidth: 950, minHeight: 750, frame: true, // still buggy cant select items from the custom titlebar autoHideMenuBar: true, - icon: join(process.env.PUBLIC, '../src/assets/images/logo.png'), webPreferences: { preload, // Warning: Enable nodeIntegration and disable contextIsolation is not secure in production @@ -61,12 +76,13 @@ async function createWindow() { const remoteMain = require('@electron/remote/main'); remoteMain.initialize(); - if (app.isPackaged) { - win.loadFile(indexHtml); - } else { + if (process.env.VITE_DEV_SERVER_URL) { + // electron-vite-vue#298 win.loadURL(url); // Open devTool if the app is not packaged win.webContents.openDevTools(); + } else { + win.loadFile(indexHtml); } // Test actively push message to the Electron-Renderer @@ -75,7 +91,6 @@ async function createWindow() { 'main-process-message', new Date().toLocaleString() ); - win.setTitle(`Vigad`); }); // Make all links open with the browser, not with the application @@ -99,8 +114,14 @@ async function createWindow() { }); } +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +// Some APIs can only be used after this event occurs. app.whenReady().then(createWindow); +// Quit when all windows are closed, except on macOS. +// There, it's common for applications and their menu bar +// to stay active until the user quits explicitly with Cmd + Q. app.on('window-all-closed', () => { win = null; if (process.platform !== 'darwin') app.quit(); @@ -124,18 +145,19 @@ app.on('activate', () => { }); // new window example arg: new windows url -ipcMain.handle('open-win', (event, arg) => { +ipcMain.handle('open-win', (_, arg) => { const childWindow = new BrowserWindow({ webPreferences: { preload, + nodeIntegration: true, + contextIsolation: true, }, }); - if (app.isPackaged) { - childWindow.loadFile(indexHtml, { hash: arg }); + if (process.env.VITE_DEV_SERVER_URL) { + childWindow.loadURL(`${url}#${arg}`); } else { - childWindow.loadURL(`${url}/#${arg}`); - // childWindow.webContents.openDevTools({ mode: "undocked", activate: true }) + childWindow.loadFile(indexHtml, { hash: arg }); } }); @@ -147,15 +169,5 @@ async function getScreen(event, title) { types: ['window', 'screen'], }); - // const videoOptionsMenu = Menu.buildFromTemplate([ - // allSources.map((source) => { - // return { - // label: source.name, - // click: () => selectSource(source), - // } - // }), - // ]) - - // videoOptionsMenu.popup() return allSources; } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c742f95..a847547 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,31 +1,31 @@ function domReady( - condition: DocumentReadyState[] = ['complete', 'interactive'] + condition: DocumentReadyState[] = ['complete', 'interactive'] ) { - return new Promise((resolve) => { - if (condition.includes(document.readyState)) { - resolve(true) - } else { - document.addEventListener('readystatechange', () => { + return new Promise((resolve) => { if (condition.includes(document.readyState)) { - resolve(true) + resolve(true); + } else { + document.addEventListener('readystatechange', () => { + if (condition.includes(document.readyState)) { + resolve(true); + } + }); } - }) - } - }) + }); } const safeDOM = { - append(parent: HTMLElement, child: HTMLElement) { - if (!Array.from(parent.children).find((e) => e === child)) { - return parent.appendChild(child) - } - }, - remove(parent: HTMLElement, child: HTMLElement) { - if (Array.from(parent.children).find((e) => e === child)) { - return parent.removeChild(child) - } - }, -} + append(parent: HTMLElement, child: HTMLElement) { + if (!Array.from(parent.children).find((e) => e === child)) { + return parent.appendChild(child); + } + }, + remove(parent: HTMLElement, child: HTMLElement) { + if (Array.from(parent.children).find((e) => e === child)) { + return parent.removeChild(child); + } + }, +}; /** * https://tobiasahlin.com/spinkit @@ -34,8 +34,8 @@ const safeDOM = { * https://matejkustec.github.io/SpinThatShit */ function useLoading() { - const className = `loaders-css__square-spin` - const styleContent = ` + const className = `loaders-css__square-spin`; + const styleContent = ` @keyframes square-spin { 25% { transform: perspective(100px) rotateX(180deg) rotateY(0); } 50% { transform: perspective(100px) rotateX(180deg) rotateY(180deg); } @@ -61,75 +61,75 @@ function useLoading() { background: #282c34; z-index: 9; } - ` - const oStyle = document.createElement('style') - const oDiv = document.createElement('div') + `; + const oStyle = document.createElement('style'); + const oDiv = document.createElement('div'); - oStyle.id = 'app-loading-style' - oStyle.innerHTML = styleContent - oDiv.className = 'app-loading-wrap' - oDiv.innerHTML = `
` + oStyle.id = 'app-loading-style'; + oStyle.innerHTML = styleContent; + oDiv.className = 'app-loading-wrap'; + oDiv.innerHTML = `
`; - return { - appendLoading() { - safeDOM.append(document.head, oStyle) - safeDOM.append(document.body, oDiv) - }, - removeLoading() { - safeDOM.remove(document.head, oStyle) - safeDOM.remove(document.body, oDiv) - }, - } + return { + appendLoading() { + safeDOM.append(document.head, oStyle); + safeDOM.append(document.body, oDiv); + }, + removeLoading() { + safeDOM.remove(document.head, oStyle); + safeDOM.remove(document.body, oDiv); + }, + }; } // ---------------------------------------------------------------------- -const { appendLoading, removeLoading } = useLoading() -domReady().then(appendLoading) +const { appendLoading, removeLoading } = useLoading(); +domReady().then(appendLoading); window.onmessage = (ev) => { - ev.data.payload === 'removeLoading' && removeLoading() -} + ev.data.payload === 'removeLoading' && removeLoading(); +}; -setTimeout(removeLoading, 4999) +setTimeout(removeLoading, 4999); // ! Working with informations from the main process and the renderer process -const { contextBridge, ipcRenderer } = require('electron') +const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { - getMedia: () => ipcRenderer.invoke('get-screens'), - minimizeScreen: () => ipcRenderer.invoke('minimize-screen'), - fullScreen: () => ipcRenderer.invoke('full-screen'), - closeApplication: () => ipcRenderer.invoke('close-application'), -}) + getMedia: () => ipcRenderer.invoke('get-screens'), + minimizeScreen: () => ipcRenderer.invoke('minimize-screen'), + fullScreen: () => ipcRenderer.invoke('full-screen'), + closeApplication: () => ipcRenderer.invoke('close-application'), +}); ipcRenderer.on('SET_SOURCE', async (event, sourceId) => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: false, - video: { - mandatory: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: sourceId, - minWidth: 1280, - maxWidth: 3840, - minHeight: 720, - maxHeight: 2160, - }, - }, - }) - handleStream(stream) - } catch (e) { - handleError(e) - } -}) + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: sourceId, + minWidth: 1280, + maxWidth: 3840, + minHeight: 720, + maxHeight: 2160, + }, + }, + }); + handleStream(stream); + } catch (e) { + handleError(e); + } +}); function handleStream(stream) { - const video = document.querySelector('video') - video.srcObject = stream - video.onloadedmetadata = (e) => video.play() + const video = document.querySelector('video'); + video.srcObject = stream; + video.onloadedmetadata = (e) => video.play(); } function handleError(e) { - console.log(e) + console.log(e); } diff --git a/index.html b/index.html index 69b1cf2..63717c0 100644 --- a/index.html +++ b/index.html @@ -2,10 +2,10 @@ - + - Vite Apps + Vigad
diff --git a/package-lock.json b/package-lock.json index fe7d6cb..720e9be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,8 @@ "typescript": "^4.9.3", "vite": "^3.2.4", "vite-electron-plugin": "^0.5.2", + "vite-plugin-electron": "^0.11.2", + "vite-plugin-electron-renderer": "^0.14.1", "vitest": "^0.30.1", "vue": "^3.2.45", "vue-cli-plugin-vuetify": "^2.5.8", @@ -5103,6 +5105,12 @@ "node": ">= 0.8.0" } }, + "node_modules/lib-esm": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/lib-esm/-/lib-esm-0.3.0.tgz", + "integrity": "sha512-P7YcG7OnoaGL2h4j46g/m0P2xHMXlYf+0iCDvVrEfzUVxLe+abytgd2VjUhj9puqEgqRGEDbT504YWj75jHacA==", + "dev": true + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -7142,6 +7150,21 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/vite-plugin-electron": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.11.2.tgz", + "integrity": "sha512-umQRmSuA80JVxKB3PfO55o8mFTrW+sEtu7kZ5TYKAnkuYpKw7qgxl4f/65gp8x5BGHJjYh/iIRIE26x3Xqc4mQ==", + "dev": true + }, + "node_modules/vite-plugin-electron-renderer": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.1.tgz", + "integrity": "sha512-1s+FwZs+m4PUFPwtpYfxs3SNYEGOTdQlz3HCuRs3st/kWX20582gCTf/30PysF/uewexe6laSL151TqsENbmHg==", + "dev": true, + "dependencies": { + "lib-esm": "~0.3.0" + } + }, "node_modules/vitest": { "version": "0.30.1", "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.30.1.tgz", @@ -11490,6 +11513,12 @@ "type-check": "~0.4.0" } }, + "lib-esm": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/lib-esm/-/lib-esm-0.3.0.tgz", + "integrity": "sha512-P7YcG7OnoaGL2h4j46g/m0P2xHMXlYf+0iCDvVrEfzUVxLe+abytgd2VjUhj9puqEgqRGEDbT504YWj75jHacA==", + "dev": true + }, "loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -12926,6 +12955,21 @@ "vite": "^3.0.0 || ^4.0.0" } }, + "vite-plugin-electron": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.11.2.tgz", + "integrity": "sha512-umQRmSuA80JVxKB3PfO55o8mFTrW+sEtu7kZ5TYKAnkuYpKw7qgxl4f/65gp8x5BGHJjYh/iIRIE26x3Xqc4mQ==", + "dev": true + }, + "vite-plugin-electron-renderer": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.1.tgz", + "integrity": "sha512-1s+FwZs+m4PUFPwtpYfxs3SNYEGOTdQlz3HCuRs3st/kWX20582gCTf/30PysF/uewexe6laSL151TqsENbmHg==", + "dev": true, + "requires": { + "lib-esm": "~0.3.0" + } + }, "vitest": { "version": "0.30.1", "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.30.1.tgz", diff --git a/package.json b/package.json index 86238f7..fa6626d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "test": "vitest --environment happy-dom", "test-ui": "vitest --ui --environment happy-dom", "test-coverage": "vitest run --coverage --environment happy-dom", - "build": "vue-tsc --noEmit && vite build && electron-builder", + "build:win": "vue-tsc --noEmit && vite build && electron-builder --win", + "build:mac": "vue-tsc --noEmit && vite build && electron-builder --mac", + "build:linux": "vue-tsc --noEmit && vite build && electron-builder --linux", + "build:all": "vue-tsc --noEmit && vite build && electron-builder --mac --win --linux", "gen-uml": "tplant --input src/proc/**/*.ts --output docs/proc-uml.svg -A" }, "engines": { @@ -31,7 +34,8 @@ "sass": "^1.56.1", "typescript": "^4.9.3", "vite": "^3.2.4", - "vite-electron-plugin": "^0.5.2", + "vite-plugin-electron": "^0.11.2", + "vite-plugin-electron-renderer": "^0.14.1", "vitest": "^0.30.1", "vue": "^3.2.45", "vue-cli-plugin-vuetify": "^2.5.8", diff --git a/public/electron-vite-vue.gif b/public/electron-vite-vue.gif deleted file mode 100644 index 66a14af..0000000 Binary files a/public/electron-vite-vue.gif and /dev/null differ diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index df36fcf..0000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/icon.icns b/public/icon.icns new file mode 100644 index 0000000..b2e77dd Binary files /dev/null and b/public/icon.icns differ diff --git a/public/icon.ico b/public/icon.ico new file mode 100644 index 0000000..c027819 Binary files /dev/null and b/public/icon.ico differ diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..5677414 Binary files /dev/null and b/public/icon.png differ diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..b9cfc1c --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/node.png b/public/node.png deleted file mode 100644 index 1cd6519..0000000 Binary files a/public/node.png and /dev/null differ diff --git a/src/env.d.ts b/src/vite-env.d.ts similarity index 100% rename from src/env.d.ts rename to src/vite-env.d.ts diff --git a/vite.config.ts b/vite.config.ts index 88d78b4..ff81a00 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,63 +2,100 @@ import { fileURLToPath, URL } from 'url'; import { rmSync } from 'fs'; import { defineConfig } from 'vitest/config'; import vue from '@vitejs/plugin-vue'; -import electron from 'vite-electron-plugin'; -import { customStart } from 'vite-electron-plugin/plugin'; +// import electron from 'vite-electron-plugin'; +import electron from 'vite-plugin-electron'; +import renderer from 'vite-plugin-electron-renderer'; import pkg from './package.json'; rmSync('dist-electron', { recursive: true, force: true }); // https://vitejs.dev/config/ -export default defineConfig({ - test: { - coverage: { - provider: 'c8', - reporter: ['text', 'html'], - reportsDirectory: './tests/unit/coverage', +export default defineConfig(({ command }) => { + rmSync('dist-electron', { recursive: true, force: true }); + + const isServe = command === 'serve'; + const isBuild = command === 'build'; + const sourcemap = isServe || !!process.env.VSCODE_DEBUG; + + return { + test: { + // Unit-Test coverage report settings and output directory + coverage: { + provider: 'c8', + reporter: ['text', 'html'], + reportsDirectory: './tests/unit/coverage', + }, }, - }, - plugins: [ - vue(), - electron({ - include: ['electron'], - transformOptions: { - sourcemap: !!process.env.VSCODE_DEBUG, + plugins: [ + vue(), + electron([ + { + // Main-Process entry file of the Electron App. + entry: 'electron/main/index.ts', + onstart(options) { + if (process.env.VSCODE_DEBUG) { + console.log( + /* For `.vscode/.debug.script.mjs` */ '[startup] Electron App' + ); + } else { + options.startup(); + } + }, + vite: { + build: { + sourcemap, + minify: isBuild, + outDir: 'dist-electron/main', + rollupOptions: { + external: Object.keys( + 'dependencies' in pkg + ? pkg.dependencies + : {} + ), + }, + }, + }, + }, + { + entry: 'electron/preload/index.ts', + onstart(options) { + // Notify the Renderer-Process to reload the page when the Preload-Scripts build is complete, + // instead of restarting the entire Electron App. + options.reload(); + }, + vite: { + build: { + sourcemap: sourcemap ? 'inline' : undefined, + minify: isBuild, + outDir: 'dist-electron/preload', + rollupOptions: { + external: Object.keys( + 'dependencies' in pkg + ? pkg.dependencies + : {} + ), + }, + }, + }, + }, + ]), + // Use Node.js API in the Renderer-process + renderer(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), }, - // Will start Electron via VSCode Debug - plugins: process.env.VSCODE_DEBUG - ? [ - customStart( - debounce(() => - console.log( - /* For `.vscode/.debug.script.mjs` */ '[startup] Electron App' - ) - ) - ), - ] - : undefined, - }), - ], - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), }, - }, - server: process.env.VSCODE_DEBUG - ? (() => { - const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL); - return { - host: url.hostname, - port: +url.port, - }; - })() - : undefined, - clearScreen: false, + server: + process.env.VSCODE_DEBUG && + (() => { + const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL); + return { + host: url.hostname, + port: +url.port, + }; + })(), + clearScreen: false, + }; }); - -function debounce void>(fn: Fn, delay = 299) { - let t: NodeJS.Timeout; - return ((...args) => { - clearTimeout(t); - t = setTimeout(() => fn(...args), delay); - }) as Fn; -}