├── .circleci └── config.yml ├── .editorconfig ├── .gitattributes ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .npmrc ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── PRIVACY.md ├── README.md ├── build ├── background.png ├── background@2x.png ├── entitlements.mac.inherit.plist └── icon.icns ├── contributing.md ├── docs └── plugins.md ├── main ├── aperture.ts ├── common │ ├── accelerator-validator.ts │ ├── analytics.ts │ ├── constants.ts │ ├── flags.ts │ ├── settings.ts │ ├── system-permissions.ts │ └── types │ │ ├── base.ts │ │ ├── conversion-options.ts │ │ ├── index.ts │ │ ├── remote-states.ts │ │ └── window-states.ts ├── conversion.ts ├── converters │ ├── h264.ts │ ├── index.ts │ ├── process.ts │ └── utils.ts ├── export.ts ├── global-accelerators.ts ├── index.ts ├── menus │ ├── application.ts │ ├── cog.ts │ ├── common.ts │ ├── record.ts │ └── utils.ts ├── plugins │ ├── built-in │ │ ├── copy-to-clipboard-plugin.ts │ │ ├── open-with-plugin.ts │ │ └── save-file-plugin.ts │ ├── config.ts │ ├── index.ts │ ├── plugin.ts │ ├── service-context.ts │ └── service.ts ├── recording-history.ts ├── remote-states │ ├── editor-options.ts │ ├── exports-list.ts │ ├── exports.ts │ ├── index.ts │ ├── setup-remote-state.ts │ └── utils.ts ├── tray.ts ├── utils │ ├── ajv.ts │ ├── deep-linking.ts │ ├── devices.ts │ ├── dock.ts │ ├── encoding.ts │ ├── errors.ts │ ├── ffmpeg-path.ts │ ├── format-time.ts │ ├── formats.ts │ ├── fps.ts │ ├── image-preview.ts │ ├── macos-release.ts │ ├── notifications.ts │ ├── open-files.ts │ ├── protocol.ts │ ├── routes.ts │ ├── sentry.ts │ ├── shortcut-to-accelerator.ts │ ├── timestamped-name.ts │ ├── track-duration.ts │ └── windows.ts ├── video.ts └── windows │ ├── config.ts │ ├── cropper.ts │ ├── dialog.ts │ ├── editor.ts │ ├── exports.ts │ ├── kap-window.ts │ ├── load.ts │ ├── manager.ts │ └── preferences.ts ├── maintaining.md ├── media └── plugins │ └── hexColor.png ├── package.json ├── renderer ├── common ├── components │ ├── action-bar │ │ ├── controls │ │ │ ├── advanced.js │ │ │ └── main.js │ │ ├── index.js │ │ └── record-button.js │ ├── config │ │ ├── index.js │ │ └── tab.js │ ├── cropper │ │ ├── cursor.js │ │ ├── handles.js │ │ ├── index.js │ │ └── overlay.js │ ├── dialog │ │ ├── actions.js │ │ ├── body.js │ │ └── icon.js │ ├── editor │ │ ├── controls │ │ │ ├── left.tsx │ │ │ ├── play-bar.tsx │ │ │ ├── preview.tsx │ │ │ └── right.tsx │ │ ├── conversion │ │ │ ├── conversion-details.tsx │ │ │ ├── index.tsx │ │ │ ├── title-bar.tsx │ │ │ └── video-preview.tsx │ │ ├── editor-preview.tsx │ │ ├── index.tsx │ │ ├── options-container.tsx │ │ ├── options │ │ │ ├── index.tsx │ │ │ ├── left.tsx │ │ │ ├── right.tsx │ │ │ ├── select.tsx │ │ │ └── slider.tsx │ │ ├── video-controls-container.tsx │ │ ├── video-metadata-container.tsx │ │ ├── video-player.tsx │ │ ├── video-time-container.tsx │ │ └── video.tsx │ ├── exports │ │ ├── export.tsx │ │ ├── index.tsx │ │ └── progress.tsx │ ├── icon-menu.tsx │ ├── keyboard-number-input.js │ ├── preferences │ │ ├── categories │ │ │ ├── category.js │ │ │ ├── general.js │ │ │ ├── index.js │ │ │ └── plugins │ │ │ │ ├── index.js │ │ │ │ ├── plugin.js │ │ │ │ └── tab.js │ │ ├── item │ │ │ ├── button.js │ │ │ ├── color-picker.js │ │ │ ├── index.js │ │ │ ├── select.js │ │ │ └── switch.js │ │ ├── navigation.js │ │ └── shortcut-input.js │ ├── traffic-lights.tsx │ └── window-header.js ├── containers │ ├── action-bar.js │ ├── config.js │ ├── cropper.js │ ├── cursor.js │ ├── index.js │ └── preferences.js ├── hooks │ ├── dark-mode.tsx │ ├── editor │ │ ├── use-conversion-id.tsx │ │ ├── use-conversion.tsx │ │ ├── use-editor-options.tsx │ │ ├── use-editor-window-state.tsx │ │ ├── use-share-plugins.tsx │ │ └── use-window-size.tsx │ ├── exports │ │ └── use-exports-list.tsx │ ├── use-confirmation.tsx │ ├── use-current-window.tsx │ ├── use-keyboard-action.tsx │ ├── use-remote-state.tsx │ ├── use-show-window.tsx │ └── window-state.tsx ├── next-env.d.ts ├── next.config.js ├── pages │ ├── _app.tsx │ ├── config.js │ ├── cropper.js │ ├── dialog.js │ ├── editor.tsx │ ├── exports.tsx │ └── preferences.js ├── public │ └── static │ │ ├── all-the-things.png │ │ └── kap-icon.png ├── tsconfig.eslint.json ├── tsconfig.json ├── utils │ ├── combine-unstated-containers.tsx │ ├── format-time.js │ ├── global-styles.tsx │ ├── inputs.js │ ├── sentry-error-boundary.tsx │ └── window.ts └── vectors │ ├── applications.js │ ├── back-plain.tsx │ ├── back.js │ ├── cancel.js │ ├── crop.js │ ├── dropdown-arrow.js │ ├── edit.js │ ├── error.js │ ├── exit-fullscreen.js │ ├── fullscreen.js │ ├── gear.js │ ├── help.js │ ├── index.js │ ├── link.js │ ├── more.js │ ├── open-config.js │ ├── open-on-github.js │ ├── pause.js │ ├── play.js │ ├── plugins.js │ ├── settings.js │ ├── spinner.js │ ├── svg.tsx │ ├── swap.js │ ├── tooltip.js │ ├── tune.js │ ├── volume-high.js │ └── volume-off.js ├── static ├── menubar-loading │ ├── loading_00000Template.png │ ├── loading_00000Template@2x.png │ ├── loading_00001Template.png │ ├── loading_00001Template@2x.png │ ├── loading_00002Template.png │ ├── loading_00002Template@2x.png │ ├── loading_00003Template.png │ ├── loading_00003Template@2x.png │ ├── loading_00004Template.png │ ├── loading_00004Template@2x.png │ ├── loading_00005Template.png │ ├── loading_00005Template@2x.png │ ├── loading_00006Template.png │ ├── loading_00006Template@2x.png │ ├── loading_00007Template.png │ ├── loading_00007Template@2x.png │ ├── loading_00008Template.png │ ├── loading_00008Template@2x.png │ ├── loading_00009Template.png │ ├── loading_00009Template@2x.png │ ├── loading_00010Template.png │ ├── loading_00010Template@2x.png │ ├── loading_00011Template.png │ ├── loading_00011Template@2x.png │ ├── loading_00012Template.png │ ├── loading_00012Template@2x.png │ ├── loading_00013Template.png │ ├── loading_00013Template@2x.png │ ├── loading_00014Template.png │ ├── loading_00014Template@2x.png │ ├── loading_00015Template.png │ ├── loading_00015Template@2x.png │ ├── loading_00016Template.png │ ├── loading_00016Template@2x.png │ ├── loading_00017Template.png │ ├── loading_00017Template@2x.png │ ├── loading_00018Template.png │ ├── loading_00018Template@2x.png │ ├── loading_00019Template.png │ ├── loading_00019Template@2x.png │ ├── loading_00020Template.png │ ├── loading_00020Template@2x.png │ ├── loading_00021Template.png │ ├── loading_00021Template@2x.png │ ├── loading_00022Template.png │ ├── loading_00022Template@2x.png │ ├── loading_00023Template.png │ ├── loading_00023Template@2x.png │ ├── loading_00024Template.png │ ├── loading_00024Template@2x.png │ ├── loading_00025Template.png │ ├── loading_00025Template@2x.png │ ├── loading_00026Template.png │ ├── loading_00026Template@2x.png │ ├── loading_00027Template.png │ ├── loading_00027Template@2x.png │ ├── loading_00028Template.png │ ├── loading_00028Template@2x.png │ ├── loading_00029Template.png │ ├── loading_00029Template@2x.png │ ├── loading_00030Template.png │ ├── loading_00030Template@2x.png │ ├── loading_00031Template.png │ ├── loading_00031Template@2x.png │ ├── loading_00032Template.png │ ├── loading_00032Template@2x.png │ ├── loading_00033Template.png │ ├── loading_00033Template@2x.png │ ├── loading_00034Template.png │ ├── loading_00034Template@2x.png │ ├── loading_00035Template.png │ ├── loading_00035Template@2x.png │ ├── loading_00036Template.png │ ├── loading_00036Template@2x.png │ ├── loading_00037Template.png │ ├── loading_00037Template@2x.png │ ├── loading_00038Template.png │ ├── loading_00038Template@2x.png │ ├── loading_00039Template.png │ ├── loading_00039Template@2x.png │ ├── loading_00040Template.png │ ├── loading_00040Template@2x.png │ ├── loading_00041Template.png │ ├── loading_00041Template@2x.png │ ├── loading_00042Template.png │ ├── loading_00042Template@2x.png │ ├── loading_00043Template.png │ ├── loading_00043Template@2x.png │ ├── loading_00044Template.png │ ├── loading_00044Template@2x.png │ ├── loading_00045Template.png │ ├── loading_00045Template@2x.png │ ├── loading_00046Template.png │ ├── loading_00046Template@2x.png │ ├── loading_00047Template.png │ ├── loading_00047Template@2x.png │ ├── loading_00048Template.png │ ├── loading_00048Template@2x.png │ ├── loading_00049Template.png │ ├── loading_00049Template@2x.png │ ├── loading_00050Template.png │ ├── loading_00050Template@2x.png │ ├── loading_00051Template.png │ ├── loading_00051Template@2x.png │ ├── loading_00052Template.png │ ├── loading_00052Template@2x.png │ ├── loading_00053Template.png │ ├── loading_00053Template@2x.png │ ├── loading_00054Template.png │ ├── loading_00054Template@2x.png │ ├── loading_00055Template.png │ ├── loading_00055Template@2x.png │ ├── loading_00056Template.png │ ├── loading_00056Template@2x.png │ ├── loading_00057Template.png │ ├── loading_00057Template@2x.png │ ├── loading_00058Template.png │ ├── loading_00058Template@2x.png │ ├── loading_00059Template.png │ ├── loading_00059Template@2x.png │ ├── loading_00060Template.png │ ├── loading_00060Template@2x.png │ ├── loading_00061Template.png │ ├── loading_00061Template@2x.png │ ├── loading_00062Template.png │ ├── loading_00062Template@2x.png │ ├── loading_00063Template.png │ └── loading_00063Template@2x.png ├── menubarDefaultTemplate.png ├── menubarDefaultTemplate@2x.png ├── pauseTemplate.png └── pauseTemplate@2x.png ├── test ├── convert.ts ├── fixtures │ ├── corrupt.mp4 │ ├── incomplete.mp4 │ ├── input.mp4 │ └── input@2x.mp4 ├── helpers │ ├── assertions.ts │ ├── mocks.ts │ └── video-utils.ts ├── mocks │ ├── analytics.ts │ ├── dialog.ts │ ├── electron-store.ts │ ├── electron.ts │ ├── plugins.ts │ ├── sentry.ts │ ├── service-context.ts │ ├── settings.ts │ ├── video.ts │ └── window-manager.ts ├── recording-history.ts └── tsconfig.json ├── tsconfig.eslint.json ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | macos: 5 | xcode: '13.4.1' 6 | steps: 7 | - checkout 8 | - run: yarn 9 | - run: mkdir -p ~/reports 10 | - run: yarn lint 11 | - run: yarn test:ci 12 | - run: yarn run dist 13 | - run: mv dist/*-x64.dmg dist/Kap-x64.dmg 14 | - run: mv dist/*-arm64.dmg dist/Kap-arm64.dmg 15 | - store_artifacts: 16 | path: dist/Kap-x64.dmg 17 | - store_artifacts: 18 | path: dist/Kap-arm64.dmg 19 | - store_test_results: 20 | path: ~/reports 21 | sentry-release: 22 | docker: 23 | - image: cimg/node:lts 24 | environment: 25 | SENTRY_ORG: wulkano-l0 26 | SENTRY_PROJECT: kap 27 | steps: 28 | - checkout 29 | - run: | 30 | curl -sL https://sentry.io/get-cli/ | bash 31 | export SENTRY_RELEASE=$(yarn run -s sentry-version) 32 | sentry-cli releases new -p $SENTRY_PROJECT $SENTRY_RELEASE 33 | sentry-cli releases set-commits --auto $SENTRY_RELEASE 34 | sentry-cli releases finalize $SENTRY_RELEASE 35 | 36 | workflows: 37 | version: 2 38 | build: 39 | jobs: 40 | - build: 41 | filters: 42 | tags: 43 | only: /.*/ # Force CircleCI to build on tags 44 | - sentry-release: 45 | requires: 46 | - build 47 | filters: 48 | tags: 49 | only: /^v.*/ 50 | branches: 51 | ignore: /.*/ 52 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | **macOS version:** 15 | **Kap version:** 16 | 17 | #### Steps to reproduce 18 | 19 | #### Current behaviour 20 | 21 | #### Expected behaviour 22 | 23 | #### Workaround 24 | 25 | 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /renderer/out 3 | /renderer/.next 4 | /app/dist 5 | /dist 6 | /dist-js 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Wulkano hello@wulkano.com (https://wulkano.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

Kap

4 |

An open-source screen recorder built with web technology

5 |

Build Status XO code style

6 |

7 | 8 | [![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://vshymanskyy.github.io/StandWithUkraine/) 9 | 10 | ## Get Kap 11 | 12 | Download the latest release: 13 | 14 | - [Apple silicon](https://getkap.co/api/download/arm64) 15 | - [Intel](https://getkap.co/api/download/x64) 16 | 17 | Or install with [Homebrew-Cask](https://caskroom.github.io): 18 | 19 | ```sh 20 | brew install --cask kap 21 | ``` 22 | 23 | ## How To Use Kap 24 | 25 | Click the menu bar icon to bring up the screen recorder. After selecting what portion of the screen you'd like to record, hit the record button to start recording. Click the menu bar icon again to stop the recording. 26 | 27 | > Tip: While recording, Option-click the menu bar icon to pause or right-click for more options. 28 | 29 | ## Contribute 30 | 31 | Read the [contribution guide](contributing.md). 32 | 33 | ## Plugins 34 | 35 | For more info on how to create plugins, read the [plugins docs](docs/plugins.md). 36 | 37 | ## Dev builds 38 | 39 | Download [`main`](https://kap-artifacts.now.sh/main) or builds for any other branch using: `https://kap-artifacts.now.sh/`. Note that these builds are unsupported and may have issues. 40 | 41 | ## Related Repositories 42 | 43 | - [Website](https://github.com/wulkano/kap-website) 44 | - [Aperture](https://github.com/wulkano/aperture) 45 | 46 | ## Newsletter 47 | 48 | [Subscribe](http://eepurl.com/ch90_1) 49 | 50 | ## Thanks 51 | 52 | - [▲ Vercel](https://vercel.com/) for fast deployments served from the edge, hosting our website, downloads, and updates. 53 | - [● CircleCI](https://circleci.com/) for supporting the open source community and making our builds fast and reliable. 54 | - [△ Sentry](https://sentry.io/) for letting us know when Kap isn't behaving and helping us eradicate said behaviour. 55 | - Our [contributors](https://github.com/wulkano/kap/contributors) who help maintain Kap and make screen recording and sharing easy. 56 | -------------------------------------------------------------------------------- /build/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/build/background.png -------------------------------------------------------------------------------- /build/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/build/background@2x.png -------------------------------------------------------------------------------- /build/entitlements.mac.inherit.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | com.apple.security.device.audio-input 12 | 13 | com.apple.security.device.camera 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/build/icon.icns -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device 4 | 2. Install the dependencies: `yarn` 5 | 3. Build the code, start the app, and watch for changes: `yarn start` 6 | 7 | To make sure that your code works in the finished app, you can generate the binary: 8 | 9 | ``` 10 | $ yarn run pack 11 | ``` 12 | 13 | After that, you'll see the binary in the `dist` folder 😀 14 | -------------------------------------------------------------------------------- /main/common/analytics.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import util from 'electron-util'; 4 | import {parse} from 'semver'; 5 | import {settings} from './settings'; 6 | 7 | // TODO: Disabled because of https://github.com/wulkano/Kap/issues/1126 8 | /// const Insight = require('insight'); 9 | const pkg = require('../../package'); 10 | 11 | /// const trackingCode = 'UA-84705099-2'; 12 | /// const insight = new Insight({trackingCode, pkg}); 13 | 14 | const version = parse(pkg.version); 15 | 16 | export const track = (...paths: string[]) => { 17 | const allowAnalytics = settings.get('allowAnalytics'); 18 | 19 | if (allowAnalytics) { 20 | console.log('Tracking', `v${version?.major}.${version?.minor}`, ...paths); 21 | /// insight.track(`v${version?.major}.${version?.minor}`, ...paths); 22 | } 23 | }; 24 | 25 | export const initializeAnalytics = () => { 26 | if (util.isFirstAppLaunch()) { 27 | /// insight.track('install'); 28 | } 29 | 30 | if (settings.get('version') !== pkg.version) { 31 | track('install'); 32 | settings.set('version', pkg.version); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /main/common/constants.ts: -------------------------------------------------------------------------------- 1 | import {Format} from './types'; 2 | 3 | export const supportedVideoExtensions = ['mp4', 'mov', 'm4v']; 4 | 5 | const formatExtensions = new Map([ 6 | ['av1', 'mp4'], 7 | ['hevc', 'mp4'] 8 | ]); 9 | 10 | export const formats = [Format.mp4, Format.hevc, Format.av1, Format.gif, Format.apng, Format.webm]; 11 | 12 | export const getFormatExtension = (format: Format) => formatExtensions.get(format) ?? format; 13 | 14 | export const defaultInputDeviceId = 'SYSTEM_DEFAULT'; 15 | -------------------------------------------------------------------------------- /main/common/flags.ts: -------------------------------------------------------------------------------- 1 | import Store from 'electron-store'; 2 | 3 | export const flags = new Store<{ 4 | backgroundEditorConversion: boolean; 5 | editorDragTooltip: boolean; 6 | }>({ 7 | name: 'flags', 8 | defaults: { 9 | backgroundEditorConversion: false, 10 | editorDragTooltip: false 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /main/common/types/base.ts: -------------------------------------------------------------------------------- 1 | import {Rectangle} from 'electron'; 2 | 3 | export enum Format { 4 | gif = 'gif', 5 | hevc = 'hevc', 6 | mp4 = 'mp4', 7 | webm = 'webm', 8 | apng = 'apng', 9 | av1 = 'av1' 10 | } 11 | 12 | export enum Encoding { 13 | h264 = 'h264', 14 | hevc = 'hevc', 15 | // eslint-disable-next-line unicorn/prevent-abbreviations 16 | proRes422 = 'proRes422', 17 | // eslint-disable-next-line unicorn/prevent-abbreviations 18 | proRes4444 = 'proRes4444' 19 | } 20 | 21 | export type App = { 22 | url: string; 23 | isDefault: boolean; 24 | icon: string; 25 | name: string; 26 | }; 27 | 28 | export interface ApertureOptions { 29 | fps: number; 30 | cropArea: Rectangle; 31 | showCursor: boolean; 32 | highlightClicks: boolean; 33 | screenId: number; 34 | audioDeviceId?: string; 35 | videoCodec?: Encoding; 36 | } 37 | 38 | export interface StartRecordingOptions { 39 | cropperBounds: Rectangle; 40 | screenBounds: Rectangle; 41 | displayId: number; 42 | } 43 | -------------------------------------------------------------------------------- /main/common/types/conversion-options.ts: -------------------------------------------------------------------------------- 1 | import {App, Format} from './base'; 2 | 3 | export type CreateExportOptions = { 4 | filePath: string; 5 | conversionOptions: ConversionOptions; 6 | format: Format; 7 | plugins: { 8 | share: { 9 | pluginName: string; 10 | serviceTitle: string; 11 | app?: App; 12 | }; 13 | }; 14 | }; 15 | 16 | export type EditServiceInfo = { 17 | pluginName: string; 18 | serviceTitle: string; 19 | }; 20 | 21 | export type ConversionOptions = { 22 | startTime: number; 23 | endTime: number; 24 | width: number; 25 | height: number; 26 | fps: number; 27 | shouldCrop: boolean; 28 | shouldMute: boolean; 29 | editService?: EditServiceInfo; 30 | }; 31 | 32 | export enum ExportStatus { 33 | inProgress = 'inProgress', 34 | failed = 'failed', 35 | canceled = 'canceled', 36 | completed = 'completed' 37 | } 38 | -------------------------------------------------------------------------------- /main/common/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base'; 2 | export * from './remote-states'; 3 | export * from './conversion-options'; 4 | export * from './window-states'; 5 | -------------------------------------------------------------------------------- /main/common/types/remote-states.ts: -------------------------------------------------------------------------------- 1 | import {App, Format} from './base'; 2 | import {ExportStatus} from './conversion-options'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/ban-types 5 | export type RemoteState any> = {}> = { 6 | actions: Actions; 7 | state: State; 8 | }; 9 | 10 | export type RemoteStateHook = Base extends RemoteState ? ( 11 | Actions & { 12 | state: State; 13 | isLoading: boolean; 14 | refreshState: () => void; 15 | } 16 | ) : never; 17 | 18 | export type RemoteStateHandler = Base extends RemoteState ? (sendUpdate: (state: State, id?: string) => void) => { 19 | actions: { 20 | [Key in keyof Actions]: Actions[Key] extends (...args: any[]) => any ? (id: string, ...args: Parameters) => void : never 21 | }; 22 | getState: (id: string) => State | undefined; 23 | } : never; 24 | 25 | export interface ExportOptionsPlugin { 26 | title: string; 27 | pluginName: string; 28 | pluginPath: string; 29 | apps?: App[]; 30 | lastUsed: number; 31 | } 32 | 33 | export type ExportOptionsFormat = { 34 | plugins: ExportOptionsPlugin[]; 35 | format: Format; 36 | prettyFormat: string; 37 | lastUsed: number; 38 | }; 39 | 40 | export type ExportOptionsEditService = { 41 | title: string; 42 | pluginName: string; 43 | pluginPath: string; 44 | hasConfig: boolean; 45 | }; 46 | 47 | export type ExportOptions = { 48 | formats: ExportOptionsFormat[]; 49 | editServices: ExportOptionsEditService[]; 50 | fpsHistory: {[key in Format]: number}; 51 | }; 52 | 53 | export type EditorOptionsRemoteState = RemoteState void; 58 | updateFpsUsage: ({format, fps}: { 59 | format: Format; 60 | fps: number; 61 | }) => void; 62 | }>; 63 | 64 | export interface ExportState { 65 | id: string; 66 | title: string; 67 | description: string; 68 | message: string; 69 | progress?: number; 70 | image?: string; 71 | filePath?: string; 72 | error?: Error; 73 | fileSize?: string; 74 | status: ExportStatus; 75 | canCopy: boolean; 76 | disableOutputActions: boolean; 77 | canPreviewExport: boolean; 78 | titleWithFormat: string; 79 | } 80 | 81 | export type ExportsRemoteState = RemoteState void; 83 | cancel: () => void; 84 | retry: () => void; 85 | openInEditor: () => void; 86 | showInFolder: () => void; 87 | }>; 88 | 89 | export type ExportsListRemoteState = RemoteState; 90 | -------------------------------------------------------------------------------- /main/common/types/window-states.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface EditorWindowState { 3 | fps: number; 4 | previewFilePath: string; 5 | filePath: string; 6 | title: string; 7 | conversionId?: string; 8 | } 9 | -------------------------------------------------------------------------------- /main/converters/utils.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import prettyMilliseconds from 'pretty-ms'; 3 | 4 | export interface ConvertOptions { 5 | inputPath: string; 6 | outputPath: string; 7 | shouldCrop: boolean; 8 | startTime: number; 9 | endTime: number; 10 | width: number; 11 | height: number; 12 | fps: number; 13 | shouldMute: boolean; 14 | onCancel: () => void; 15 | onProgress: (action: string, progress: number, estimate?: string) => void; 16 | editService?: { 17 | pluginName: string; 18 | serviceTitle: string; 19 | }; 20 | } 21 | 22 | export const makeEven = (number: number) => 2 * Math.round(number / 2); 23 | 24 | export const areDimensionsEven = ({width, height}: {width: number; height: number}) => width % 2 === 0 && height % 2 === 0; 25 | 26 | export const extractProgressFromStderr = (stderr: string, conversionStartTime: number, durationMs: number) => { 27 | const conversionDuration = Date.now() - conversionStartTime; 28 | const data = stderr.trim(); 29 | 30 | const speed = Number.parseFloat(/speed=\s*(-?\d+(,\d+)*(\.\d+(e\d+)?)?)/gm.exec(data)?.[1] ?? '0'); 31 | const processedMs = moment.duration(/time=\s*(\d\d:\d\d:\d\d.\d\d)/gm.exec(data)?.[1] ?? 0).asMilliseconds(); 32 | 33 | if (speed > 0) { 34 | const progress = processedMs / durationMs; 35 | 36 | // Wait 2 seconds in the conversion for speed to be stable 37 | // Either 2 seconds of the video or 15 seconds real time (for super slow conversion like AV1) 38 | if (processedMs > 2 * 1000 || conversionDuration > 15 * 1000) { 39 | const msRemaining = (durationMs - processedMs) / speed; 40 | 41 | return { 42 | progress, 43 | estimate: prettyMilliseconds(Math.max(msRemaining, 1000), {compact: true}) 44 | }; 45 | } 46 | 47 | return {progress}; 48 | } 49 | 50 | return undefined; 51 | }; 52 | 53 | type ArgType = string[] | string | {args: string[]; if: boolean}; 54 | 55 | // Resolve conditional args 56 | // 57 | // conditionalArgs(['default', 'args'], {args: ['ignore', 'these'], if: false}); 58 | // => ['default', 'args'] 59 | export const conditionalArgs = (...args: ArgType[]): string[] => { 60 | return args.flatMap(arg => { 61 | if (typeof arg === 'string') { 62 | return [arg]; 63 | } 64 | 65 | if (Array.isArray(arg)) { 66 | return arg; 67 | } 68 | 69 | return arg.if ? arg.args : []; 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /main/global-accelerators.ts: -------------------------------------------------------------------------------- 1 | import {globalShortcut} from 'electron'; 2 | import {ipcMain as ipc} from 'electron-better-ipc'; 3 | import {settings} from './common/settings'; 4 | import {windowManager} from './windows/manager'; 5 | 6 | const openCropper = () => { 7 | if (!windowManager.cropper?.isOpen()) { 8 | windowManager.cropper?.open(); 9 | } 10 | }; 11 | 12 | // All settings that should be loaded and handled as global accelerators 13 | const handlers = new Map void>([ 14 | ['triggerCropper', openCropper] 15 | ]); 16 | 17 | // If no action is passed, it resets 18 | export const setCropperShortcutAction = (action = openCropper) => { 19 | if (settings.get('enableShortcuts') && settings.get('shortcuts.triggerCropper')) { 20 | handlers.set('cropperShortcut', action); 21 | 22 | const shortcut = settings.get('shortcuts.triggerCropper'); 23 | if (globalShortcut.isRegistered(shortcut)) { 24 | globalShortcut.unregister(shortcut); 25 | } 26 | 27 | globalShortcut.register(shortcut, action); 28 | } 29 | }; 30 | 31 | const registerShortcut = (shortcut: string, action: () => void) => { 32 | try { 33 | globalShortcut.register(shortcut, action); 34 | } catch (error) { 35 | console.error('Error registering shortcut', shortcut, action, error); 36 | } 37 | }; 38 | 39 | const registerFromStore = () => { 40 | if (settings.get('enableShortcuts')) { 41 | for (const [setting, action] of handlers.entries()) { 42 | const shortcut = settings.get(`shortcuts.${setting}`); 43 | if (shortcut) { 44 | registerShortcut(shortcut, action); 45 | } 46 | } 47 | } else { 48 | globalShortcut.unregisterAll(); 49 | } 50 | }; 51 | 52 | export const initializeGlobalAccelerators = () => { 53 | ipc.answerRenderer('update-shortcut', ({setting, shortcut}) => { 54 | const oldShortcut = settings.get(`shortcuts.${setting}`); 55 | 56 | try { 57 | if (oldShortcut && oldShortcut !== shortcut && globalShortcut.isRegistered(oldShortcut)) { 58 | globalShortcut.unregister(oldShortcut); 59 | } 60 | } catch (error) { 61 | console.error('Error unregistering old shortcutAccelerator', error); 62 | } finally { 63 | if (shortcut && shortcut !== oldShortcut) { 64 | settings.set(`shortcuts.${setting}`, shortcut); 65 | const handler = handlers.get(setting); 66 | 67 | if (settings.get('enableShortcuts') && handler) { 68 | registerShortcut(shortcut, handler); 69 | } 70 | } else if (!shortcut) { 71 | // @ts-expect-error 72 | settings.delete(`shortcuts.${setting}`); 73 | } 74 | } 75 | }); 76 | 77 | ipc.answerRenderer('toggle-shortcuts', ({enabled}) => { 78 | if (enabled) { 79 | registerFromStore(); 80 | } else { 81 | globalShortcut.unregisterAll(); 82 | } 83 | }); 84 | 85 | // Register keyboard shortcuts from store 86 | registerFromStore(); 87 | }; 88 | -------------------------------------------------------------------------------- /main/menus/application.ts: -------------------------------------------------------------------------------- 1 | import {appMenu} from 'electron-util'; 2 | import {getAboutMenuItem, getExportHistoryMenuItem, getOpenFileMenuItem, getPreferencesMenuItem, getSendFeedbackMenuItem} from './common'; 3 | import {MenuItemId, MenuOptions} from './utils'; 4 | 5 | const getAppMenuItem = () => { 6 | const appMenuItem = appMenu([getPreferencesMenuItem()]); 7 | 8 | // @ts-expect-error 9 | appMenuItem.submenu[0] = getAboutMenuItem(); 10 | return {...appMenuItem, id: MenuItemId.app}; 11 | }; 12 | 13 | // eslint-disable-next-line unicorn/prevent-abbreviations 14 | export const defaultApplicationMenu = (): MenuOptions => [ 15 | getAppMenuItem(), 16 | { 17 | role: 'fileMenu', 18 | id: MenuItemId.file, 19 | submenu: [ 20 | getOpenFileMenuItem(), 21 | { 22 | type: 'separator' 23 | }, 24 | { 25 | role: 'close' 26 | } 27 | ] 28 | }, 29 | { 30 | role: 'editMenu', 31 | id: MenuItemId.edit 32 | }, 33 | { 34 | role: 'windowMenu', 35 | id: MenuItemId.window, 36 | submenu: [ 37 | { 38 | role: 'minimize' 39 | }, 40 | { 41 | role: 'zoom' 42 | }, 43 | { 44 | type: 'separator' 45 | }, 46 | getExportHistoryMenuItem(), 47 | { 48 | type: 'separator' 49 | }, 50 | { 51 | role: 'front' 52 | } 53 | ] 54 | }, 55 | { 56 | id: MenuItemId.help, 57 | label: 'Help', 58 | role: 'help', 59 | submenu: [getSendFeedbackMenuItem()] 60 | } 61 | ]; 62 | 63 | // eslint-disable-next-line unicorn/prevent-abbreviations 64 | export const customApplicationMenu = (modifier: (defaultMenu: ReturnType) => void) => { 65 | const menu = defaultApplicationMenu(); 66 | modifier(menu); 67 | return menu; 68 | }; 69 | 70 | export type MenuModifier = Parameters[0]; 71 | -------------------------------------------------------------------------------- /main/menus/common.ts: -------------------------------------------------------------------------------- 1 | import delay from 'delay'; 2 | import {app, dialog} from 'electron'; 3 | import {openNewGitHubIssue} from 'electron-util'; 4 | import macosRelease from '../utils/macos-release'; 5 | import {supportedVideoExtensions} from '../common/constants'; 6 | import {getCurrentMenuItem, MenuItemId} from './utils'; 7 | import {openFiles} from '../utils/open-files'; 8 | import {windowManager} from '../windows/manager'; 9 | 10 | export const getPreferencesMenuItem = () => ({ 11 | id: MenuItemId.preferences, 12 | label: 'Preferences…', 13 | accelerator: 'Command+,', 14 | click: () => windowManager.preferences?.open() 15 | }); 16 | 17 | export const getAboutMenuItem = () => ({ 18 | id: MenuItemId.about, 19 | label: `About ${app.name}`, 20 | click: () => { 21 | windowManager.cropper?.close(); 22 | app.focus(); 23 | app.showAboutPanel(); 24 | } 25 | }); 26 | 27 | export const getOpenFileMenuItem = () => ({ 28 | id: MenuItemId.openVideo, 29 | label: 'Open Video…', 30 | accelerator: 'Command+O', 31 | click: async () => { 32 | windowManager.cropper?.close(); 33 | 34 | await delay(200); 35 | 36 | app.focus(); 37 | const {canceled, filePaths} = await dialog.showOpenDialog({ 38 | filters: [{name: 'Videos', extensions: supportedVideoExtensions}], 39 | properties: ['openFile', 'multiSelections'] 40 | }); 41 | 42 | if (!canceled && filePaths) { 43 | openFiles(...filePaths); 44 | } 45 | } 46 | }); 47 | 48 | export const getExportHistoryMenuItem = () => ({ 49 | label: 'Export History', 50 | click: () => windowManager.exports?.open(), 51 | enabled: getCurrentMenuItem(MenuItemId.exportHistory)?.enabled ?? false, 52 | id: MenuItemId.exportHistory 53 | }); 54 | 55 | export const getSendFeedbackMenuItem = () => ({ 56 | id: MenuItemId.sendFeedback, 57 | label: 'Send Feedback…', 58 | click() { 59 | openNewGitHubIssue({ 60 | user: 'wulkano', 61 | repo: 'kap', 62 | body: issueBody 63 | }); 64 | } 65 | }); 66 | 67 | const release = macosRelease(); 68 | 69 | const issueBody = ` 70 | 80 | 81 | **macOS version:** ${release.name} (${release.version}) 82 | **Kap version:** ${app.getVersion()} 83 | 84 | #### Steps to reproduce 85 | 86 | #### Current behavior 87 | 88 | #### Expected behavior 89 | 90 | #### Workaround 91 | 92 | 93 | `; 94 | 95 | -------------------------------------------------------------------------------- /main/menus/record.ts: -------------------------------------------------------------------------------- 1 | import {Menu} from 'electron'; 2 | import {MenuItemId, MenuOptions} from './utils'; 3 | import {pauseRecording, resumeRecording, stopRecording} from '../aperture'; 4 | import formatTime from '../utils/format-time'; 5 | import {getCurrentDurationStart, getOverallDuration} from '../utils/track-duration'; 6 | 7 | const getDurationLabel = () => { 8 | if (getCurrentDurationStart() <= 0) { 9 | return formatTime((getOverallDuration()) / 1000, undefined); 10 | } 11 | 12 | return formatTime((getOverallDuration() + (Date.now() - getCurrentDurationStart())) / 1000, undefined); 13 | }; 14 | 15 | const getDurationMenuItem = () => ({ 16 | id: MenuItemId.duration, 17 | label: getDurationLabel(), 18 | enabled: false 19 | }); 20 | 21 | const getStopRecordingMenuItem = () => ({ 22 | id: MenuItemId.stopRecording, 23 | label: 'Stop', 24 | click: stopRecording 25 | }); 26 | 27 | const getPauseRecordingMenuItem = () => ({ 28 | id: MenuItemId.pauseRecording, 29 | label: 'Pause', 30 | click: pauseRecording 31 | }); 32 | 33 | const getResumeRecordingMenuItem = () => ({ 34 | id: MenuItemId.resumeRecording, 35 | label: 'Resume', 36 | click: resumeRecording 37 | }); 38 | 39 | export const getRecordMenuTemplate = (isPaused: boolean): MenuOptions => [ 40 | getDurationMenuItem(), 41 | { 42 | type: 'separator' 43 | }, 44 | isPaused ? getResumeRecordingMenuItem() : getPauseRecordingMenuItem(), 45 | getStopRecordingMenuItem(), 46 | { 47 | type: 'separator' 48 | }, 49 | { 50 | role: 'quit', 51 | accelerator: 'Command+Q' 52 | } 53 | ]; 54 | 55 | export const getRecordMenu = async (isPaused: boolean) => { 56 | return Menu.buildFromTemplate(getRecordMenuTemplate(isPaused)); 57 | }; 58 | -------------------------------------------------------------------------------- /main/menus/utils.ts: -------------------------------------------------------------------------------- 1 | import {Menu} from 'electron'; 2 | 3 | export type MenuOptions = Parameters[0]; 4 | 5 | export enum MenuItemId { 6 | exportHistory = 'exportHistory', 7 | sendFeedback = 'sendFeedback', 8 | openVideo = 'openVideo', 9 | about = 'about', 10 | preferences = 'preferences', 11 | file = 'file', 12 | edit = 'edit', 13 | window = 'window', 14 | help = 'help', 15 | app = 'app', 16 | saveOriginal = 'saveOriginal', 17 | plugins = 'plugins', 18 | audioDevices = 'audioDevices', 19 | stopRecording = 'stopRecording', 20 | pauseRecording = 'pauseRecording', 21 | resumeRecording = 'resumeRecording', 22 | duration = 'duration' 23 | } 24 | 25 | export const getCurrentMenuItem = (id: MenuItemId) => { 26 | return Menu.getApplicationMenu()?.getMenuItemById(id); 27 | }; 28 | 29 | export const setExportMenuItemState = (enabled: boolean) => { 30 | const menuItem = Menu.getApplicationMenu()?.getMenuItemById(MenuItemId.exportHistory); 31 | 32 | if (menuItem) { 33 | menuItem.enabled = enabled; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /main/plugins/built-in/copy-to-clipboard-plugin.ts: -------------------------------------------------------------------------------- 1 | import {clipboard} from 'electron'; 2 | import {ShareServiceContext} from '../service-context'; 3 | 4 | const plist = require('plist'); 5 | 6 | const copyFileReferencesToClipboard = (filePaths: string[]) => { 7 | clipboard.writeBuffer('NSFilenamesPboardType', Buffer.from(plist.build(filePaths))); 8 | }; 9 | 10 | const action = async (context: ShareServiceContext) => { 11 | const filePath = await context.filePath(); 12 | copyFileReferencesToClipboard([filePath]); 13 | context.notify(`The ${context.prettyFormat} has been copied to the clipboard`); 14 | }; 15 | 16 | const copyToClipboard = { 17 | title: 'Copy to Clipboard', 18 | formats: [ 19 | 'gif', 20 | 'apng', 21 | 'mp4' 22 | ], 23 | action 24 | }; 25 | 26 | export const shareServices = [copyToClipboard]; 27 | -------------------------------------------------------------------------------- /main/plugins/built-in/open-with-plugin.ts: -------------------------------------------------------------------------------- 1 | import {ShareServiceContext} from '../service-context'; 2 | import path from 'path'; 3 | import {getFormatExtension} from '../../common/constants'; 4 | import {Format} from '../../common/types'; 5 | 6 | const {getAppsThatOpenExtension, openFileWithApp} = require('mac-open-with'); 7 | 8 | const action = async (context: ShareServiceContext & {appUrl: string}) => { 9 | const filePath = await context.filePath(); 10 | openFileWithApp(filePath, context.appUrl); 11 | }; 12 | 13 | export interface App { 14 | url: string; 15 | isDefault: boolean; 16 | icon: string; 17 | name: string; 18 | } 19 | 20 | const getAppsForFormat = (format: Format) => { 21 | return (getAppsThatOpenExtension.sync(getFormatExtension(format)) as App[]) 22 | .map(app => ({...app, name: decodeURI(path.parse(app.url).name)})) 23 | .filter(app => !['Kap', 'Kap Beta'].includes(app.name)) 24 | .sort((a, b) => { 25 | if (a.isDefault !== b.isDefault) { 26 | return Number(b.isDefault) - Number(a.isDefault); 27 | } 28 | 29 | return Number(b.name === 'Gifski') - Number(a.name === 'Gifski'); 30 | }); 31 | }; 32 | 33 | const appsForFormat = (['mp4', 'gif', 'apng', 'webm', 'av1', 'hevc'] as Format[]) 34 | .map(format => ({ 35 | format, 36 | apps: getAppsForFormat(format) 37 | })) 38 | .filter(({apps}) => apps.length > 0); 39 | 40 | export const apps = new Map(appsForFormat.map(({format, apps}) => [format, apps])); 41 | 42 | export const shareServices = [{ 43 | title: 'Open With', 44 | formats: [...apps.keys()], 45 | action 46 | }]; 47 | -------------------------------------------------------------------------------- /main/plugins/built-in/save-file-plugin.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {BrowserWindow, dialog} from 'electron'; 4 | import {ShareServiceContext} from '../service-context'; 5 | import {settings} from '../../common/settings'; 6 | import makeDir from 'make-dir'; 7 | import {Format} from '../../common/types'; 8 | import path from 'path'; 9 | 10 | const {Notification, shell} = require('electron'); 11 | const cpFile = require('cp-file'); 12 | 13 | const action = async (context: ShareServiceContext & {targetFilePath: string}) => { 14 | const temporaryFilePath = await context.filePath(); 15 | 16 | // Execution has been interrupted 17 | if (context.isCanceled) { 18 | return; 19 | } 20 | 21 | // Copy the file, so we can still use the temporary source for future exports 22 | // The temporary file will be cleaned up on app exit, or automatic system cleanup 23 | await cpFile(temporaryFilePath, context.targetFilePath); 24 | 25 | const notification = new Notification({ 26 | title: 'File saved successfully!', 27 | body: 'Click to show the file in Finder' 28 | }); 29 | 30 | notification.on('click', () => { 31 | shell.showItemInFolder(context.targetFilePath); 32 | }); 33 | 34 | notification.show(); 35 | }; 36 | 37 | const saveFile = { 38 | title: 'Save to Disk', 39 | formats: [ 40 | 'gif', 41 | 'mp4', 42 | 'webm', 43 | 'apng', 44 | 'av1', 45 | 'hevc' 46 | ], 47 | action 48 | }; 49 | 50 | export const shareServices = [saveFile]; 51 | 52 | const filterMap = new Map([ 53 | [Format.mp4, [{name: 'Movies', extensions: ['mp4']}]], 54 | [Format.webm, [{name: 'Movies', extensions: ['webm']}]], 55 | [Format.gif, [{name: 'Images', extensions: ['gif']}]], 56 | [Format.apng, [{name: 'Images', extensions: ['apng']}]], 57 | [Format.av1, [{name: 'Movies', extensions: ['mp4']}]], 58 | [Format.hevc, [{name: 'Movies', extensions: ['mp4']}]] 59 | ]); 60 | 61 | let lastSavedDirectory: string; 62 | 63 | export const askForTargetFilePath = async ( 64 | window: BrowserWindow, 65 | format: Format, 66 | fileName: string 67 | ) => { 68 | const kapturesDir = settings.get('kapturesDir'); 69 | await makeDir(kapturesDir); 70 | 71 | const defaultPath = path.join(lastSavedDirectory ?? kapturesDir, fileName); 72 | 73 | const filters = filterMap.get(format); 74 | 75 | const {filePath} = await dialog.showSaveDialog(window, { 76 | title: fileName, 77 | defaultPath, 78 | filters 79 | }); 80 | 81 | if (filePath) { 82 | lastSavedDirectory = path.dirname(filePath); 83 | return filePath; 84 | } 85 | 86 | return undefined; 87 | }; 88 | -------------------------------------------------------------------------------- /main/plugins/config.ts: -------------------------------------------------------------------------------- 1 | import {ValidateFunction} from 'ajv'; 2 | import Store, {Schema as JSONSchema} from 'electron-store'; 3 | import Ajv, {Schema} from '../utils/ajv'; 4 | import {Service} from './service'; 5 | 6 | export default class PluginConfig extends Store { 7 | servicesWithNoConfig: Service[]; 8 | validators: Array<{ 9 | title: string; 10 | description?: string; 11 | config: Record; 12 | validate: ValidateFunction; 13 | }>; 14 | 15 | constructor(name: string, services: Service[]) { 16 | const defaults = {}; 17 | 18 | const validators = services 19 | .filter(({config}) => Boolean(config)) 20 | .map(service => { 21 | const config = service.config as Record; 22 | const schema: Record> = {}; 23 | const requiredKeys = []; 24 | 25 | for (const key of Object.keys(config)) { 26 | if (!config[key].title) { 27 | throw new Error('Config schema items should have a `title`'); 28 | } 29 | 30 | const {required, ...rest} = config[key]; 31 | 32 | if (required) { 33 | requiredKeys.push(key); 34 | } 35 | 36 | schema[key] = rest; 37 | } 38 | 39 | const ajv = new Ajv({ 40 | format: 'full', 41 | useDefaults: true, 42 | errorDataPath: 'property', 43 | allErrors: true 44 | }); 45 | 46 | const validator = ajv.compile({ 47 | type: 'object', 48 | properties: schema, 49 | required: requiredKeys 50 | }); 51 | 52 | validator(defaults); 53 | return { 54 | validate: validator, 55 | title: service.title, 56 | description: service.configDescription, 57 | config 58 | }; 59 | }); 60 | 61 | super({ 62 | name, 63 | cwd: 'plugins', 64 | defaults 65 | }); 66 | 67 | this.servicesWithNoConfig = services.filter(({config}) => !config); 68 | this.validators = validators; 69 | } 70 | 71 | get isValid() { 72 | return this.validators.every(validator => validator.validate(this.store)); 73 | } 74 | 75 | get validServices() { 76 | return [ 77 | ...this.validators.filter(validator => validator.validate(this.store)), 78 | ...this.servicesWithNoConfig 79 | ].map(service => service.title); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /main/plugins/service.ts: -------------------------------------------------------------------------------- 1 | 2 | import PCancelable from 'p-cancelable'; 3 | import {Format} from '../common/types'; 4 | import {Schema} from '../utils/ajv'; 5 | import {EditServiceContext, RecordServiceContext, ShareServiceContext} from './service-context'; 6 | 7 | export interface Service { 8 | title: string; 9 | configDescription?: string; 10 | config?: {[P in keyof Config]: Schema}; 11 | } 12 | 13 | export interface ShareService extends Service { 14 | formats: Format[]; 15 | action: (context: ShareServiceContext) => PromiseLike | PCancelable; 16 | } 17 | 18 | export interface EditService extends Service { 19 | action: (context: EditServiceContext) => PromiseLike | PCancelable; 20 | } 21 | 22 | export type RecordServiceHook = 'willStartRecording' | 'didStartRecording' | 'didStopRecording'; 23 | 24 | export type RecordService = Service & { 25 | [key in RecordServiceHook]: ((context: RecordServiceContext) => PromiseLike) | undefined; 26 | } & { 27 | willEnable?: () => PromiseLike; 28 | cleanUp?: (persistedState: Record) => void; 29 | }; 30 | -------------------------------------------------------------------------------- /main/remote-states/exports-list.ts: -------------------------------------------------------------------------------- 1 | import {ExportsListRemoteState, RemoteStateHandler} from '../common/types'; 2 | import Export from '../export'; 3 | 4 | const exportsListRemoteState: RemoteStateHandler = sendUpdate => { 5 | const getState = () => { 6 | return [...Export.exportsMap.keys()]; 7 | }; 8 | 9 | const subscribe = () => { 10 | const callback = () => { 11 | sendUpdate([...Export.exportsMap.keys()]); 12 | }; 13 | 14 | Export.events.on('added', callback); 15 | return () => { 16 | Export.events.off('added', callback); 17 | }; 18 | }; 19 | 20 | return { 21 | subscribe, 22 | getState, 23 | actions: {} 24 | }; 25 | }; 26 | 27 | export default exportsListRemoteState; 28 | export const name = 'exports-list'; 29 | -------------------------------------------------------------------------------- /main/remote-states/exports.ts: -------------------------------------------------------------------------------- 1 | import {shell} from 'electron'; 2 | import {ExportsRemoteState, RemoteStateHandler} from '../common/types'; 3 | import Export from '../export'; 4 | 5 | const exportsRemoteState: RemoteStateHandler = sendUpdate => { 6 | const getState = (exportId: string) => { 7 | const exportInstance = Export.fromId(exportId); 8 | 9 | if (!exportInstance) { 10 | return; 11 | } 12 | 13 | return exportInstance.data; 14 | }; 15 | 16 | const subscribe = (exportId: string) => { 17 | const exportInstance = Export.fromId(exportId); 18 | 19 | if (!exportInstance) { 20 | return; 21 | } 22 | 23 | const callback = () => { 24 | sendUpdate(exportInstance.data, exportId); 25 | }; 26 | 27 | exportInstance.on('updated', callback); 28 | return () => { 29 | exportInstance.off('updated', callback); 30 | }; 31 | }; 32 | 33 | const actions = { 34 | cancel: (exportId: string) => { 35 | Export.fromId(exportId)?.cancel(); 36 | }, 37 | copy: (exportId: string) => { 38 | Export.fromId(exportId)?.conversion?.copy(); 39 | }, 40 | retry: (exportId: string) => { 41 | Export.fromId(exportId)?.retry(); 42 | }, 43 | openInEditor: (exportId: string) => { 44 | Export.fromId(exportId)?.video?.openEditorWindow?.(); 45 | }, 46 | showInFolder: (exportId: string) => { 47 | const exportInstance = Export.fromId(exportId); 48 | 49 | if (!exportInstance) { 50 | return; 51 | } 52 | 53 | if (exportInstance.finalFilePath && !exportInstance.data.disableOutputActions) { 54 | shell.showItemInFolder(exportInstance.finalFilePath); 55 | } 56 | } 57 | } as any; 58 | 59 | return { 60 | subscribe, 61 | getState, 62 | actions 63 | }; 64 | }; 65 | 66 | export default exportsRemoteState; 67 | export const name = 'exports'; 68 | -------------------------------------------------------------------------------- /main/remote-states/index.ts: -------------------------------------------------------------------------------- 1 | import setupRemoteState from './setup-remote-state'; 2 | 3 | const remoteStateNames = ['editor-options', 'exports', 'exports-list']; 4 | 5 | export const setupRemoteStates = async () => { 6 | return Promise.all(remoteStateNames.map(async fileName => { 7 | const state = require(`./${fileName}`); 8 | console.log(`Setting up remote-state: ${state.name}`); 9 | setupRemoteState(state.name, state.default); 10 | })); 11 | }; 12 | -------------------------------------------------------------------------------- /main/remote-states/setup-remote-state.ts: -------------------------------------------------------------------------------- 1 | import {RemoteState, getChannelNames} from './utils'; 2 | import {ipcMain} from 'electron-better-ipc'; 3 | import {BrowserWindow} from 'electron'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/ban-types 6 | const setupRemoteState = async >(name: string, callback: RemoteState) => { 7 | const channelNames = getChannelNames(name); 8 | 9 | const renderersMap = new Map>(); 10 | 11 | const sendUpdate = async (state?: State, id?: string) => { 12 | if (id) { 13 | const renderers = renderersMap.get(id) ?? new Set(); 14 | 15 | for (const renderer of renderers) { 16 | ipcMain.callRenderer(renderer, channelNames.stateUpdated, {state, id}); 17 | } 18 | 19 | return; 20 | } 21 | 22 | for (const [windowId, renderers] of renderersMap.entries()) { 23 | for (const renderer of renderers) { 24 | if (renderer && !renderer.isDestroyed()) { 25 | ipcMain.callRenderer(renderer, channelNames.stateUpdated, {state: state ?? (await getState?.(windowId))}); 26 | } else { 27 | renderers.delete(renderer); 28 | } 29 | } 30 | } 31 | }; 32 | 33 | const {getState, actions = {}, subscribe} = await callback(sendUpdate); 34 | 35 | ipcMain.answerRenderer(channelNames.subscribe, (customId: string, window: BrowserWindow) => { 36 | const id = customId ?? window.id.toString(); 37 | 38 | if (!renderersMap.has(id)) { 39 | renderersMap.set(id, new Set()); 40 | } 41 | 42 | renderersMap.get(id)?.add(window); 43 | const unsubscribe = subscribe?.(id); 44 | 45 | window.on('close', () => { 46 | renderersMap.get(id)?.delete(window); 47 | unsubscribe?.(); 48 | }); 49 | 50 | return Object.keys(actions); 51 | }); 52 | 53 | ipcMain.answerRenderer(channelNames.getState, async (customId: string, window: BrowserWindow) => { 54 | const id = customId ?? window.id.toString(); 55 | return getState(id); 56 | }); 57 | 58 | ipcMain.answerRenderer(channelNames.callAction, ({key, data, id: customId}: any, window: BrowserWindow) => { 59 | const id = customId || window.id.toString(); 60 | return (actions as any)[key]?.(id, ...data); 61 | }); 62 | }; 63 | 64 | export default setupRemoteState; 65 | -------------------------------------------------------------------------------- /main/remote-states/utils.ts: -------------------------------------------------------------------------------- 1 | import {Promisable} from 'type-fest'; 2 | 3 | export const getChannelName = (name: string, action: string) => `kap-remote-state-${name}-${action}`; 4 | 5 | export const getChannelNames = (name: string) => ({ 6 | subscribe: getChannelName(name, 'subscribe'), 7 | getState: getChannelName(name, 'get-state'), 8 | callAction: getChannelName(name, 'call-action'), 9 | stateUpdated: getChannelName(name, 'state-updated') 10 | }); 11 | 12 | // eslint-disable-next-line @typescript-eslint/ban-types 13 | export type RemoteState> = (sendUpdate: (state?: State, id?: string) => void) => Promisable<{ 14 | getState: (id?: string) => Promisable; 15 | actions: Actions; 16 | subscribe?: (id?: string) => undefined | (() => void); 17 | }>; 18 | -------------------------------------------------------------------------------- /main/utils/ajv.ts: -------------------------------------------------------------------------------- 1 | import Ajv, {Options} from 'ajv'; 2 | import {Schema as JSONSchema} from 'electron-store'; 3 | import {Except} from 'type-fest'; 4 | 5 | export type Schema = Except, 'required'> & { 6 | required?: boolean; 7 | customType?: string; 8 | }; 9 | 10 | const hexColorValidator = () => { 11 | return { 12 | type: 'string', 13 | pattern: /^((0x)|#)([\dA-Fa-f]{8}|[\dA-Fa-f]{6})$/.source 14 | }; 15 | }; 16 | 17 | const keyboardShortcutValidator = () => { 18 | return { 19 | type: 'string' 20 | }; 21 | }; 22 | 23 | // eslint-disable-next-line @typescript-eslint/ban-types 24 | const validators = new Map object>([ 25 | ['hexColor', hexColorValidator], 26 | ['keyboardShortcut', keyboardShortcutValidator] 27 | ]); 28 | 29 | export default class CustomAjv extends Ajv { 30 | constructor(options: Options) { 31 | super(options); 32 | 33 | this.addKeyword('customType', { 34 | // eslint-disable-next-line @typescript-eslint/ban-types 35 | macro: (schema: string, parentSchema: object) => { 36 | const validator = validators.get(schema); 37 | 38 | if (!validator) { 39 | throw new Error(`No custom type found for ${schema}`); 40 | } 41 | 42 | return validator(parentSchema); 43 | }, 44 | metaSchema: { 45 | type: 'string', 46 | enum: [...validators.keys()] 47 | } 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /main/utils/deep-linking.ts: -------------------------------------------------------------------------------- 1 | import {windowManager} from '../windows/manager'; 2 | 3 | const pluginPromises = new Map void>(); 4 | 5 | const handlePluginsDeepLink = (path: string) => { 6 | const [plugin, ...rest] = path.split('/'); 7 | 8 | if (pluginPromises.has(plugin)) { 9 | pluginPromises.get(plugin)?.(rest.join('/')); 10 | pluginPromises.delete(plugin); 11 | return; 12 | } 13 | 14 | console.error(`Received link for plugin “${plugin}” but there was no registered listener.`); 15 | }; 16 | 17 | export const addPluginPromise = (plugin: string, resolveFunction: (path: string) => void) => { 18 | pluginPromises.set(plugin, resolveFunction); 19 | }; 20 | 21 | const triggerPluginAction = (action: string) => (name: string) => windowManager.preferences?.open({target: {name, action}}); 22 | 23 | const routes = new Map([ 24 | ['plugins', handlePluginsDeepLink], 25 | ['install-plugin', triggerPluginAction('install')], 26 | ['configure-plugin', triggerPluginAction('configure')] 27 | ]); 28 | 29 | export const handleDeepLink = (url: string) => { 30 | const {host, pathname} = new URL(url); 31 | 32 | if (routes.has(host)) { 33 | return routes.get(host)?.(pathname.slice(1)); 34 | } 35 | 36 | console.error(`Route not recognized: ${host} (${url}).`); 37 | }; 38 | -------------------------------------------------------------------------------- /main/utils/devices.ts: -------------------------------------------------------------------------------- 1 | import {hasMicrophoneAccess} from '../common/system-permissions'; 2 | import * as audioDevices from 'macos-audio-devices'; 3 | import {settings} from '../common/settings'; 4 | import {defaultInputDeviceId} from '../common/constants'; 5 | import Sentry from './sentry'; 6 | const aperture = require('aperture'); 7 | 8 | const {showError} = require('./errors'); 9 | 10 | export const getAudioDevices = async () => { 11 | if (!hasMicrophoneAccess()) { 12 | return []; 13 | } 14 | 15 | try { 16 | const devices = await audioDevices.getInputDevices(); 17 | 18 | return devices.sort((a, b) => { 19 | if (a.transportType === b.transportType) { 20 | return a.name.localeCompare(b.name); 21 | } 22 | 23 | if (a.transportType === 'builtin') { 24 | return -1; 25 | } 26 | 27 | if (b.transportType === 'builtin') { 28 | return 1; 29 | } 30 | 31 | return 0; 32 | }).map(device => ({id: device.uid, name: device.name})); 33 | } catch (error) { 34 | try { 35 | const devices = await aperture.audioDevices(); 36 | 37 | if (!Array.isArray(devices)) { 38 | Sentry.captureException(new Error(`devices is not an array: ${JSON.stringify(devices)}`)); 39 | showError(error); 40 | return []; 41 | } 42 | 43 | return devices; 44 | } catch (error) { 45 | showError(error); 46 | return []; 47 | } 48 | } 49 | }; 50 | 51 | export const getDefaultInputDevice = () => { 52 | try { 53 | const device = audioDevices.getDefaultInputDevice.sync(); 54 | return { 55 | id: device.uid, 56 | name: device.name 57 | }; 58 | } catch { 59 | // Running on 10.13 and don't have swift support libs. No need to report 60 | return undefined; 61 | } 62 | }; 63 | 64 | export const getSelectedInputDeviceId = () => { 65 | const audioInputDeviceId = settings.get('audioInputDeviceId', defaultInputDeviceId); 66 | 67 | if (audioInputDeviceId === defaultInputDeviceId) { 68 | const device = getDefaultInputDevice(); 69 | return device?.id; 70 | } 71 | 72 | return audioInputDeviceId; 73 | }; 74 | 75 | export const initializeDevices = async () => { 76 | const audioInputDeviceId = settings.get('audioInputDeviceId'); 77 | 78 | if (hasMicrophoneAccess()) { 79 | const devices = await getAudioDevices(); 80 | 81 | if (!devices.some((device: any) => device.id === audioInputDeviceId)) { 82 | settings.set('audioInputDeviceId', defaultInputDeviceId); 83 | } 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /main/utils/dock.ts: -------------------------------------------------------------------------------- 1 | import {app} from 'electron'; 2 | import {Promisable} from 'type-fest'; 3 | 4 | export const ensureDockIsShowing = async (action: () => Promisable) => { 5 | const wasDockShowing = app.dock.isVisible(); 6 | if (!wasDockShowing) { 7 | await app.dock.show(); 8 | } 9 | 10 | await action(); 11 | 12 | if (!wasDockShowing) { 13 | app.dock.hide(); 14 | } 15 | }; 16 | 17 | export const ensureDockIsShowingSync = (action: () => void) => { 18 | const wasDockShowing = app.dock.isVisible(); 19 | if (!wasDockShowing) { 20 | app.dock.show(); 21 | } 22 | 23 | action(); 24 | 25 | if (!wasDockShowing) { 26 | app.dock.hide(); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /main/utils/encoding.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable array-element-newline */ 2 | 3 | import path from 'path'; 4 | import execa from 'execa'; 5 | import tempy from 'tempy'; 6 | import {track} from '../common/analytics'; 7 | import ffmpegPath from './ffmpeg-path'; 8 | 9 | export const getEncoding = async (filePath: string) => { 10 | try { 11 | await execa(ffmpegPath, ['-i', filePath]); 12 | return undefined; 13 | } catch (error) { 14 | return /.*: Video: (.*?) \(.*/.exec((error as any)?.stderr)?.[1]; 15 | } 16 | }; 17 | 18 | // `ffmpeg -i original.mp4 -vcodec libx264 -crf 27 -preset veryfast -c:a copy output.mp4` 19 | export const convertToH264 = async (inputPath: string) => { 20 | const outputPath = tempy.file({extension: path.extname(inputPath)}); 21 | 22 | track('encoding/converted/hevc'); 23 | 24 | await execa(ffmpegPath, [ 25 | '-i', inputPath, 26 | '-vcodec', 'libx264', 27 | '-crf', '27', 28 | '-preset', 'veryfast', 29 | '-c:a', 'copy', 30 | outputPath 31 | ]); 32 | 33 | return outputPath; 34 | }; 35 | -------------------------------------------------------------------------------- /main/utils/ffmpeg-path.ts: -------------------------------------------------------------------------------- 1 | import ffmpeg from 'ffmpeg-static'; 2 | import util from 'electron-util'; 3 | 4 | const ffmpegPath = util.fixPathForAsarUnpack(ffmpeg); 5 | 6 | export default ffmpegPath; 7 | -------------------------------------------------------------------------------- /main/utils/format-time.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | const formatTime = (time: number, options: any) => { 4 | options = { 5 | showMilliseconds: false, 6 | ...options 7 | }; 8 | 9 | const durationFormatted = options.extra ? 10 | ` (${format(options.extra, options)})` : 11 | ''; 12 | 13 | return `${format(time, options)}${durationFormatted}`; 14 | }; 15 | 16 | const format = (time: number, {showMilliseconds} = {showMilliseconds: false}) => { 17 | const formatString = `${time >= 60 * 60 ? 'hh:m' : ''}m:ss${showMilliseconds ? '.SS' : ''}`; 18 | 19 | return moment().startOf('day').millisecond(time * 1000).format(formatString); 20 | }; 21 | 22 | export default formatTime; 23 | -------------------------------------------------------------------------------- /main/utils/formats.ts: -------------------------------------------------------------------------------- 1 | import {Format} from '../common/types'; 2 | 3 | const formats = new Map([ 4 | [Format.gif, 'GIF'], 5 | [Format.hevc, 'MP4 (H265)'], 6 | [Format.mp4, 'MP4 (H264)'], 7 | [Format.av1, 'MP4 (AV1)'], 8 | [Format.webm, 'WebM'], 9 | [Format.apng, 'APNG'] 10 | ]); 11 | 12 | export const prettifyFormat = (format: Format): string => { 13 | return formats.get(format)!; 14 | }; 15 | -------------------------------------------------------------------------------- /main/utils/fps.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import ffmpegPath from './ffmpeg-path'; 3 | 4 | const getFps = async (filePath: string) => { 5 | try { 6 | await execa(ffmpegPath, ['-i', filePath]); 7 | return undefined; 8 | } catch (error) { 9 | return /.*, (.*) fp.*/.exec((error as any)?.stderr)?.[1]; 10 | } 11 | }; 12 | 13 | export default getFps; 14 | -------------------------------------------------------------------------------- /main/utils/image-preview.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable array-element-newline */ 2 | 3 | import {BrowserWindow, dialog} from 'electron'; 4 | import execa from 'execa'; 5 | import tempy from 'tempy'; 6 | import {promisify} from 'util'; 7 | import type {Video} from '../video'; 8 | import {generateTimestampedName} from './timestamped-name'; 9 | import ffmpegPath from './ffmpeg-path'; 10 | 11 | const base64Img = require('base64-img'); 12 | 13 | const getBase64 = promisify(base64Img.base64); 14 | 15 | export const generatePreviewImage = async (filePath: string): Promise<{path: string; data: string} | undefined> => { 16 | const previewPath = tempy.file({extension: '.jpg'}); 17 | 18 | try { 19 | await execa(ffmpegPath, [ 20 | '-ss', '0', 21 | '-i', filePath, 22 | '-t', '1', 23 | '-vframes', '1', 24 | '-f', 'image2', 25 | previewPath 26 | ]); 27 | } catch { 28 | return; 29 | } 30 | 31 | try { 32 | return { 33 | path: previewPath, 34 | data: await getBase64(previewPath) 35 | }; 36 | } catch { 37 | return { 38 | path: previewPath, 39 | data: '' 40 | }; 41 | } 42 | }; 43 | 44 | export const saveSnapshot = async (video: Video, time: number) => { 45 | const {filePath: outputPath} = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow()!, { 46 | defaultPath: generateTimestampedName('Snapshot', '.jpg') 47 | }); 48 | 49 | if (outputPath) { 50 | await execa(ffmpegPath, [ 51 | '-i', video.filePath, 52 | '-ss', time.toString(), 53 | '-vframes', '1', 54 | outputPath 55 | ]); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /main/utils/macos-release.ts: -------------------------------------------------------------------------------- 1 | // Vendored: https://github.com/sindresorhus/macos-release 2 | 3 | 'use strict'; 4 | const os = require('os'); 5 | 6 | const nameMap = { 7 | 22: ['Ventura', '13'], 8 | 21: ['Monterey', '12'], 9 | 20: ['Big Sur', '11'], 10 | 19: ['Catalina', '10.15'], 11 | 18: ['Mojave', '10.14'], 12 | 17: ['High Sierra', '10.13'], 13 | 16: ['Sierra', '10.12'], 14 | 15: ['El Capitan', '10.11'], 15 | 14: ['Yosemite', '10.10'], 16 | 13: ['Mavericks', '10.9'], 17 | 12: ['Mountain Lion', '10.8'], 18 | 11: ['Lion', '10.7'], 19 | 10: ['Snow Leopard', '10.6'], 20 | 9: ['Leopard', '10.5'], 21 | 8: ['Tiger', '10.4'], 22 | 7: ['Panther', '10.3'], 23 | 6: ['Jaguar', '10.2'], 24 | 5: ['Puma', '10.1'] 25 | } as const; 26 | 27 | export default function macosRelease(release?: string) { 28 | const releaseCleaned = (release ?? os.release()).split('.')[0] as keyof typeof nameMap; 29 | const [name, version] = nameMap[releaseCleaned] ?? ['Unknown', '']; 30 | 31 | return { 32 | name, 33 | version 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /main/utils/notifications.ts: -------------------------------------------------------------------------------- 1 | import {Notification, NotificationConstructorOptions, NotificationAction, app} from 'electron'; 2 | 3 | // Need to persist the notifications, otherwise it is garbage collected and the actions don't trigger 4 | // https://github.com/electron/electron/issues/12690 5 | const notifications = new Set(); 6 | 7 | interface Action extends NotificationAction { 8 | action?: () => void | Promise; 9 | } 10 | 11 | interface NotificationOptions extends NotificationConstructorOptions { 12 | actions?: Action[]; 13 | click?: () => void | Promise; 14 | show?: boolean; 15 | } 16 | 17 | type NotificationPromise = Promise & { 18 | show: () => void; 19 | close: () => void; 20 | }; 21 | 22 | export const notify = (options: NotificationOptions): NotificationPromise => { 23 | const notification = new Notification(options); 24 | 25 | notifications.add(notification); 26 | 27 | const promise = new Promise(resolve => { 28 | if (options.click && typeof options.click === 'function') { 29 | notification.on('click', () => { 30 | resolve(options.click?.()); 31 | }); 32 | } 33 | 34 | if (options.actions && options.actions.length > 0) { 35 | notification.on('action', (_, index) => { 36 | const button = options.actions?.[index]; 37 | 38 | if (button?.action && typeof button?.action === 'function') { 39 | resolve(button?.action?.()); 40 | } else { 41 | resolve(index); 42 | } 43 | }); 44 | } 45 | 46 | notification.on('close', () => { 47 | resolve(undefined); 48 | }); 49 | }); 50 | 51 | promise.then(() => { 52 | notifications.delete(notification); 53 | }); 54 | 55 | (promise as NotificationPromise).show = () => { 56 | notification.show(); 57 | }; 58 | 59 | (promise as NotificationPromise).close = () => { 60 | notification.close(); 61 | }; 62 | 63 | if (options.show ?? true) { 64 | notification.show(); 65 | } 66 | 67 | return promise as NotificationPromise; 68 | }; 69 | 70 | notify.simple = (text: string) => notify({title: app.name, body: text}); 71 | -------------------------------------------------------------------------------- /main/utils/open-files.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import path from 'path'; 3 | import {supportedVideoExtensions} from '../common/constants'; 4 | import {Video} from '../video'; 5 | 6 | const fileExtensions = supportedVideoExtensions.map(ext => `.${ext}`); 7 | 8 | export const openFiles = async (...filePaths: string[]) => { 9 | return Promise.all( 10 | filePaths 11 | .filter(filePath => fileExtensions.includes(path.extname(filePath).toLowerCase())) 12 | .map(async filePath => { 13 | return Video.getOrCreate({ 14 | filePath 15 | }).openEditorWindow(); 16 | }) 17 | ); 18 | }; 19 | 20 | -------------------------------------------------------------------------------- /main/utils/protocol.ts: -------------------------------------------------------------------------------- 1 | import {protocol} from 'electron'; 2 | 3 | export const setupProtocol = () => { 4 | // Fix protocol issue in order to support loading editor previews 5 | // https://github.com/electron/electron/issues/23757#issuecomment-640146333 6 | protocol.registerFileProtocol('file', (request, callback) => { 7 | const pathname = decodeURI(request.url.replace('file:///', '')); 8 | callback(pathname); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /main/utils/routes.ts: -------------------------------------------------------------------------------- 1 | import {app, BrowserWindow} from 'electron'; 2 | import {is} from 'electron-util'; 3 | 4 | export const loadRoute = (window: BrowserWindow, routeName: string, {openDevTools}: {openDevTools?: boolean} = {}) => { 5 | if (is.development) { 6 | window.loadURL(`http://localhost:8000/${routeName}`); 7 | window.webContents.openDevTools({mode: 'detach'}); 8 | } else { 9 | window.loadFile(`${app.getAppPath()}/renderer/out/${routeName}.html`); 10 | if (openDevTools) { 11 | window.webContents.openDevTools({mode: 'detach'}); 12 | } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /main/utils/sentry.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {app} from 'electron'; 4 | import {is} from 'electron-util'; 5 | import * as Sentry from '@sentry/electron'; 6 | import {settings} from '../common/settings'; 7 | 8 | const SENTRY_PUBLIC_DSN = 'https://2dffdbd619f34418817f4db3309299ce@sentry.io/255536'; 9 | 10 | export const isSentryEnabled = !is.development && settings.get('allowAnalytics'); 11 | 12 | if (isSentryEnabled) { 13 | const release = `${app.name}@${app.getVersion()}`.toLowerCase(); 14 | Sentry.init({ 15 | dsn: SENTRY_PUBLIC_DSN, 16 | release 17 | }); 18 | } 19 | 20 | export default Sentry; 21 | -------------------------------------------------------------------------------- /main/utils/shortcut-to-accelerator.ts: -------------------------------------------------------------------------------- 1 | 2 | export const shortcutToAccelerator = (shortcut: any) => { 3 | const {metaKey, altKey, ctrlKey, shiftKey, character} = shortcut; 4 | if (!character) { 5 | throw new Error(`shortcut needs character ${JSON.stringify(shortcut)}`); 6 | } 7 | 8 | const keys = [ 9 | metaKey && 'Command', 10 | altKey && 'Option', 11 | ctrlKey && 'Control', 12 | shiftKey && 'Shift', 13 | character 14 | ].filter(Boolean); 15 | return keys.join('+'); 16 | }; 17 | -------------------------------------------------------------------------------- /main/utils/timestamped-name.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export const generateTimestampedName = (title = 'Kapture', extension = '') => `${title} ${moment().format('YYYY-MM-DD')} at ${moment().format('HH.mm.ss')}${extension}`; 4 | -------------------------------------------------------------------------------- /main/utils/track-duration.ts: -------------------------------------------------------------------------------- 1 | // TODO: Add interface to aperture-node for getting recording duration instead of using this https://github.com/wulkano/aperture-node/issues/29 2 | let overallDuration = 0; 3 | let currentDurationStart = 0; 4 | 5 | export const getOverallDuration = (): number => overallDuration; 6 | 7 | export const getCurrentDurationStart = (): number => currentDurationStart; 8 | 9 | export const setOverallDuration = (duration: number): void => { 10 | overallDuration = duration; 11 | }; 12 | 13 | export const setCurrentDurationStart = (duration: number): void => { 14 | currentDurationStart = duration; 15 | }; 16 | -------------------------------------------------------------------------------- /main/windows/config.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {BrowserWindow} from 'electron'; 4 | import {ipcMain as ipc} from 'electron-better-ipc'; 5 | import pEvent from 'p-event'; 6 | 7 | import {loadRoute} from '../utils/routes'; 8 | import {windowManager} from './manager'; 9 | 10 | const openConfigWindow = async (pluginName: string) => { 11 | const prefsWindow = await windowManager.preferences?.open(); 12 | const configWindow = new BrowserWindow({ 13 | width: 320, 14 | height: 436, 15 | resizable: false, 16 | movable: false, 17 | minimizable: false, 18 | maximizable: false, 19 | fullscreenable: false, 20 | titleBarStyle: 'hiddenInset', 21 | show: false, 22 | parent: prefsWindow, 23 | modal: true, 24 | webPreferences: { 25 | nodeIntegration: true, 26 | enableRemoteModule: true, 27 | contextIsolation: false 28 | } 29 | }); 30 | 31 | loadRoute(configWindow, 'config'); 32 | 33 | configWindow.webContents.on('did-finish-load', async () => { 34 | await ipc.callRenderer(configWindow, 'plugin', pluginName); 35 | configWindow.show(); 36 | }); 37 | 38 | await pEvent(configWindow, 'closed'); 39 | }; 40 | 41 | const openEditorConfigWindow = async (pluginName: string, serviceTitle: string, editorWindow: BrowserWindow) => { 42 | const configWindow = new BrowserWindow({ 43 | width: 480, 44 | height: 420, 45 | resizable: false, 46 | movable: false, 47 | minimizable: false, 48 | maximizable: false, 49 | fullscreenable: false, 50 | titleBarStyle: 'hiddenInset', 51 | show: false, 52 | parent: editorWindow, 53 | modal: true, 54 | webPreferences: { 55 | nodeIntegration: true, 56 | enableRemoteModule: true, 57 | contextIsolation: false 58 | } 59 | }); 60 | 61 | loadRoute(configWindow, 'config'); 62 | 63 | configWindow.webContents.on('did-finish-load', async () => { 64 | await ipc.callRenderer(configWindow, 'edit-service', {pluginName, serviceTitle}); 65 | configWindow.show(); 66 | }); 67 | 68 | await pEvent(configWindow, 'closed'); 69 | }; 70 | 71 | ipc.answerRenderer('open-edit-config', async ({pluginName, serviceTitle}, window) => { 72 | return openEditorConfigWindow(pluginName, serviceTitle, window); 73 | }); 74 | 75 | windowManager.setConfig({ 76 | open: openConfigWindow 77 | }); 78 | -------------------------------------------------------------------------------- /main/windows/exports.ts: -------------------------------------------------------------------------------- 1 | import KapWindow from './kap-window'; 2 | import {windowManager} from './manager'; 3 | 4 | let exportsKapWindow: KapWindow | undefined; 5 | 6 | const openExportsWindow = async () => { 7 | if (exportsKapWindow) { 8 | exportsKapWindow.browserWindow.focus(); 9 | } else { 10 | exportsKapWindow = new KapWindow({ 11 | title: 'Exports', 12 | width: 320, 13 | height: 360, 14 | resizable: false, 15 | maximizable: false, 16 | fullscreenable: false, 17 | titleBarStyle: 'hiddenInset', 18 | frame: false, 19 | transparent: true, 20 | vibrancy: 'window', 21 | webPreferences: { 22 | nodeIntegration: true, 23 | contextIsolation: false 24 | }, 25 | route: 'exports' 26 | }); 27 | 28 | const exportsWindow = exportsKapWindow.browserWindow; 29 | 30 | const titleBarHeight = 37; 31 | exportsWindow.setSheetOffset(titleBarHeight); 32 | 33 | exportsWindow.on('close', () => { 34 | exportsKapWindow = undefined; 35 | }); 36 | 37 | await exportsKapWindow.whenReady(); 38 | } 39 | 40 | return exportsKapWindow.browserWindow; 41 | }; 42 | 43 | const getExportsWindow = () => exportsKapWindow?.browserWindow; 44 | 45 | windowManager.setExports({ 46 | open: openExportsWindow, 47 | get: getExportsWindow 48 | }); 49 | -------------------------------------------------------------------------------- /main/windows/load.ts: -------------------------------------------------------------------------------- 1 | import './editor'; 2 | import './cropper'; 3 | import './config'; 4 | import './dialog'; 5 | import './exports'; 6 | import './preferences'; 7 | -------------------------------------------------------------------------------- /main/windows/manager.ts: -------------------------------------------------------------------------------- 1 | import type {BrowserWindow} from 'electron'; 2 | import {MacWindow} from '../utils/windows'; 3 | import type {Video} from '../video'; 4 | import type {DialogOptions} from './dialog'; 5 | import type {PreferencesWindowOptions} from './preferences'; 6 | 7 | export interface EditorManager { 8 | open: (video: Video) => Promise; 9 | areAnyBlocking: () => boolean; 10 | } 11 | 12 | export interface CropperManager { 13 | open: () => Promise; 14 | close: () => void; 15 | disable: () => void; 16 | setRecording: () => void; 17 | isOpen: () => boolean; 18 | selectApp: (window: MacWindow, activateWindow: (ownerName: string) => Promise) => void; 19 | } 20 | 21 | export interface ConfigManager { 22 | open: (pluginName: string) => Promise; 23 | } 24 | 25 | export interface DialogManager { 26 | open: (options: DialogOptions) => Promise; 27 | } 28 | 29 | export interface ExportsManager { 30 | open: () => Promise; 31 | get: () => BrowserWindow | undefined; 32 | } 33 | 34 | export interface PreferencesManager { 35 | open: (options?: PreferencesWindowOptions) => Promise; 36 | close: () => void; 37 | } 38 | 39 | export class WindowManager { 40 | editor?: EditorManager; 41 | cropper?: CropperManager; 42 | config?: ConfigManager; 43 | dialog?: DialogManager; 44 | exports?: ExportsManager; 45 | preferences?: PreferencesManager; 46 | 47 | setEditor = (editorManager: EditorManager) => { 48 | this.editor = editorManager; 49 | }; 50 | 51 | setCropper = (cropperManager: CropperManager) => { 52 | this.cropper = cropperManager; 53 | }; 54 | 55 | setConfig = (configManager: ConfigManager) => { 56 | this.config = configManager; 57 | }; 58 | 59 | setDialog = (dialogManager: DialogManager) => { 60 | this.dialog = dialogManager; 61 | }; 62 | 63 | setExports = (exportsManager: ExportsManager) => { 64 | this.exports = exportsManager; 65 | }; 66 | 67 | setPreferences = (preferencesManager: PreferencesManager) => { 68 | this.preferences = preferencesManager; 69 | }; 70 | } 71 | 72 | export const windowManager = new WindowManager(); 73 | -------------------------------------------------------------------------------- /main/windows/preferences.ts: -------------------------------------------------------------------------------- 1 | import {BrowserWindow} from 'electron'; 2 | import {promisify} from 'util'; 3 | import pEvent from 'p-event'; 4 | 5 | import {ipcMain as ipc} from 'electron-better-ipc'; 6 | import {loadRoute} from '../utils/routes'; 7 | import {track} from '../common/analytics'; 8 | import {windowManager} from './manager'; 9 | 10 | let prefsWindow: BrowserWindow | undefined; 11 | 12 | export type PreferencesWindowOptions = any; 13 | 14 | const openPrefsWindow = async (options?: PreferencesWindowOptions) => { 15 | track('preferences/opened'); 16 | windowManager.cropper?.close(); 17 | 18 | if (prefsWindow) { 19 | if (options) { 20 | ipc.callRenderer(prefsWindow, 'options', options); 21 | } 22 | 23 | prefsWindow.show(); 24 | return prefsWindow; 25 | } 26 | 27 | prefsWindow = new BrowserWindow({ 28 | title: 'Preferences', 29 | width: 480, 30 | height: 480, 31 | resizable: false, 32 | minimizable: false, 33 | maximizable: false, 34 | fullscreenable: false, 35 | titleBarStyle: 'hiddenInset', 36 | show: false, 37 | frame: false, 38 | transparent: true, 39 | vibrancy: 'window', 40 | webPreferences: { 41 | nodeIntegration: true, 42 | enableRemoteModule: true, 43 | contextIsolation: false 44 | } 45 | }); 46 | 47 | const titlebarHeight = 85; 48 | prefsWindow.setSheetOffset(titlebarHeight); 49 | 50 | prefsWindow.on('close', () => { 51 | prefsWindow = undefined; 52 | }); 53 | 54 | loadRoute(prefsWindow, 'preferences'); 55 | 56 | await pEvent(prefsWindow.webContents, 'did-finish-load'); 57 | 58 | if (options) { 59 | ipc.callRenderer(prefsWindow, 'options', options); 60 | } 61 | 62 | ipc.callRenderer(prefsWindow, 'mount'); 63 | 64 | // @ts-expect-error 65 | await promisify(ipc.answerRenderer)('preferences-ready'); 66 | 67 | prefsWindow.show(); 68 | return prefsWindow; 69 | }; 70 | 71 | const closePrefsWindow = () => { 72 | if (prefsWindow) { 73 | prefsWindow.close(); 74 | } 75 | }; 76 | 77 | ipc.answerRenderer('open-preferences', openPrefsWindow); 78 | 79 | windowManager.setPreferences({ 80 | open: openPrefsWindow, 81 | close: closePrefsWindow 82 | }); 83 | -------------------------------------------------------------------------------- /maintaining.md: -------------------------------------------------------------------------------- 1 | # Maintaining 2 | 3 | ## Developing Kap 4 | 5 | Run `yarn dev` in one terminal tab to start watch mode, and in another tab, run `yarn start` to launch Kap. 6 | 7 | We strongly recommend installing an [XO editor plugin](https://github.com/sindresorhus/xo#editor-plugins) for JavaScript linting and a [Stylelint editor plugin](https://github.com/stylelint/stylelint/blob/master/docs/user-guide/integrations/editor.md) for CSS linting. Both of these support auto-fix on save. 8 | 9 | ## Releasing a new version 10 | 11 | *(You can do all the steps on github.com)* 12 | 13 | - Go to https://github.com/wulkano/kap/releases 14 | - Click `Draft a new release` 15 | - Write the new version, prefixed with `v`, in the `Tag version` field (Example: `v2.0.0`) 16 | - Leave the `Release title` field blank 17 | - Write release notes 18 | - Click `Save draft` 19 | - Change `version` [here](https://github.com/wulkano/kap/blob/main/package.json#L4) to the new version and use the version number as the commit title (Example: `2.0.0`) 20 | - CircleCI will now build the app and add the binaries to the release 21 | - When CircleCI has attached the binaries to the release, click `Edit` on the release, and then click `Publish release` 22 | 23 | ## Releasing a new beta version 24 | 25 | - Check out the `beta` branch: `git checkout beta` 26 | - Rebase from the `main` branch: `git pull --rebase origin main` 27 | - Change the `version` number in `package.json` 28 | - Amend the "Beta build customizations" commit: `git add . && git commit --amend` 29 | - Force push to the `beta` branch: `git push --force` 30 | - Tag a release with the version number in package.json and push it: `git tag -a "v2.0.0-beta.3" -m "v2.0.0-beta.3" && git push --follow-tags` 31 | - Wait for CircleCI to add the binaries to a new GitHub Releases draft 32 | - Go to the release draft that is created for you, check `This is a pre-release`, and press `Publish release` 33 | -------------------------------------------------------------------------------- /media/plugins/hexColor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/media/plugins/hexColor.png -------------------------------------------------------------------------------- /renderer/common: -------------------------------------------------------------------------------- 1 | ../main/common -------------------------------------------------------------------------------- /renderer/components/cropper/cursor.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron'; 2 | import PropTypes from 'prop-types'; 3 | import React from 'react'; 4 | import classNames from 'classnames'; 5 | 6 | import {connect, CursorContainer, CropperContainer} from '../../containers'; 7 | 8 | class Cursor extends React.Component { 9 | remote = electron.remote || false; 10 | 11 | render() { 12 | if (!this.remote) { 13 | return null; 14 | } 15 | 16 | const { 17 | cursorY, 18 | cursorX, 19 | width, 20 | height, 21 | screenWidth, 22 | screenHeight 23 | } = this.props; 24 | 25 | const className = classNames('dimensions', { 26 | flipY: screenHeight - cursorY < 35, 27 | flipX: screenWidth - cursorX < 40 28 | }); 29 | 30 | return ( 31 |
32 |
{width}
33 |
{height}
34 | 57 |
58 | ); 59 | } 60 | } 61 | 62 | Cursor.propTypes = { 63 | cursorX: PropTypes.number, 64 | cursorY: PropTypes.number, 65 | width: PropTypes.number, 66 | height: PropTypes.number, 67 | screenWidth: PropTypes.number, 68 | screenHeight: PropTypes.number 69 | }; 70 | 71 | export default connect( 72 | [CursorContainer, CropperContainer], 73 | ({cursorX, cursorY}, {screenWidth, screenHeight}) => ({cursorX, cursorY, screenWidth, screenHeight}) 74 | )(Cursor); 75 | -------------------------------------------------------------------------------- /renderer/components/cropper/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | import {connect, CropperContainer} from '../../containers'; 5 | 6 | import Handles from './handles'; 7 | import Cursor from './cursor'; 8 | 9 | class Cropper extends React.Component { 10 | render() { 11 | const {startMoving, width, height, isResizing} = this.props; 12 | 13 | return ( 14 | 15 |
18 | { isResizing && } 19 | 25 | 26 | ); 27 | } 28 | } 29 | 30 | Cropper.propTypes = { 31 | startMoving: PropTypes.elementType.isRequired, 32 | width: PropTypes.number, 33 | height: PropTypes.number, 34 | isResizing: PropTypes.bool 35 | }; 36 | 37 | export default connect( 38 | [CropperContainer], 39 | ({width, height, isResizing}) => ({width, height, isResizing}), 40 | ({startMoving}) => ({startMoving}) 41 | )(Cropper); 42 | -------------------------------------------------------------------------------- /renderer/components/dialog/actions.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect, useRef} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Actions = ({buttons, performAction, defaultId}) => { 5 | const [activeButton, setActiveButton] = useState(); 6 | const defaultButton = useRef(null); 7 | 8 | useEffect(() => { 9 | setActiveButton(); 10 | if (defaultButton.current) { 11 | defaultButton.current.focus(); 12 | } 13 | }, [buttons]); 14 | 15 | const action = async index => { 16 | setActiveButton(index); 17 | performAction(index); 18 | }; 19 | 20 | return ( 21 |
22 | { 23 | buttons.map((button, index) => ( 24 | 33 | )) 34 | } 35 | 36 | 61 |
62 | ); 63 | }; 64 | 65 | Actions.propTypes = { 66 | performAction: PropTypes.elementType, 67 | defaultId: PropTypes.number, 68 | buttons: PropTypes.arrayOf(PropTypes.object) 69 | }; 70 | 71 | export default Actions; 72 | -------------------------------------------------------------------------------- /renderer/components/dialog/body.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Body = ({title, message, detail}) => { 5 | return ( 6 |
7 |

{title}

8 |
9 | { 10 | detail.split('\n').map(text => ( 11 | {text} 12 | )) 13 | } 14 |
15 | {message &&

{message}

} 16 | 17 | 53 |
54 | ); 55 | }; 56 | 57 | Body.propTypes = { 58 | title: PropTypes.string, 59 | message: PropTypes.string, 60 | detail: PropTypes.string 61 | }; 62 | 63 | export default Body; 64 | -------------------------------------------------------------------------------- /renderer/components/dialog/icon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Icon = () => { 4 | return ( 5 |
6 | 7 | 18 |
19 | ); 20 | }; 21 | 22 | export default Icon; 23 | -------------------------------------------------------------------------------- /renderer/components/editor/controls/left.tsx: -------------------------------------------------------------------------------- 1 | import VideoControlsContainer from '../video-controls-container'; 2 | import VideoTimeContainer from '../video-time-container'; 3 | import {PlayIcon, PauseIcon} from '../../../vectors'; 4 | import formatTime from '../../../utils/format-time'; 5 | 6 | const LeftControls = () => { 7 | const {isPaused, play, pause} = VideoControlsContainer.useContainer(); 8 | const {currentTime} = VideoTimeContainer.useContainer(); 9 | 10 | return ( 11 |
12 |
13 | { 14 | isPaused ? 15 | : 16 | 17 | } 18 |
19 |
{formatTime(currentTime, {showMilliseconds: false})}
20 | 43 |
44 | ); 45 | }; 46 | 47 | export default LeftControls; 48 | -------------------------------------------------------------------------------- /renderer/components/editor/controls/right.tsx: -------------------------------------------------------------------------------- 1 | import {VolumeHighIcon, VolumeOffIcon} from '../../../vectors'; 2 | import VideoControlsContainer from '../video-controls-container'; 3 | import VideoMetadataContainer from '../video-metadata-container'; 4 | 5 | import formatTime from '../../../utils/format-time'; 6 | 7 | const RightControls = () => { 8 | const {isMuted, mute, unmute} = VideoControlsContainer.useContainer(); 9 | const {hasAudio, duration} = VideoMetadataContainer.useContainer(); 10 | 11 | // FIXME 12 | const format = 'mp4'; 13 | 14 | const canUnmute = !['gif', 'apng'].includes(format) && hasAudio; 15 | const unmuteColor = canUnmute ? '#fff' : 'rgba(255, 255, 255, 0.40)'; 16 | 17 | return ( 18 |
19 |
{formatTime(duration)}
20 |
21 | { 22 | isMuted || !hasAudio ? 23 | : 24 | 25 | } 26 |
27 | 50 |
51 | ); 52 | }; 53 | 54 | export default RightControls; 55 | -------------------------------------------------------------------------------- /renderer/components/editor/conversion/conversion-details.tsx: -------------------------------------------------------------------------------- 1 | import {UseConversionState} from 'hooks/editor/use-conversion'; 2 | 3 | const ConversionDetails = ({conversion, showInFolder}: {conversion: UseConversionState; showInFolder: () => void}) => { 4 | const message = conversion?.message; 5 | const title = conversion?.titleWithFormat; 6 | const description = conversion?.description; 7 | const size = conversion?.fileSize; 8 | 9 | return ( 10 |
11 |
{message}
12 |
13 |
14 |
{title}
15 |
{description}
16 |
17 |
{size}
18 |
19 | 70 |
71 | ); 72 | }; 73 | 74 | export default ConversionDetails; 75 | -------------------------------------------------------------------------------- /renderer/components/editor/conversion/index.tsx: -------------------------------------------------------------------------------- 1 | import {ExportStatus} from 'common/types'; 2 | import useConversion from 'hooks/editor/use-conversion'; 3 | import useConversionIdContext from 'hooks/editor/use-conversion-id'; 4 | import {useConfirmation} from 'hooks/use-confirmation'; 5 | import {useMemo} from 'react'; 6 | import {useKeyboardAction} from '../../../hooks/use-keyboard-action'; 7 | import ConversionDetails from './conversion-details'; 8 | import TitleBar from './title-bar'; 9 | import VideoPreview from './video-preview'; 10 | 11 | const dialogOptions = { 12 | message: 'Are you sure you want to discard this conversion?', 13 | detail: 'Any progress will be lost.', 14 | confirmButtonText: 'Discard' 15 | }; 16 | 17 | const EditorConversionView = ({conversionId}: {conversionId: string}) => { 18 | const {setConversionId} = useConversionIdContext(); 19 | const conversion = useConversion(conversionId); 20 | 21 | const inProgress = conversion.state?.status === ExportStatus.inProgress; 22 | 23 | const cancel = () => { 24 | if (inProgress) { 25 | conversion.cancel(); 26 | } 27 | }; 28 | 29 | const safeCancel = useConfirmation(cancel, dialogOptions); 30 | 31 | const cancelAndGoBack = () => { 32 | cancel(); 33 | setConversionId(''); 34 | }; 35 | 36 | const finalCancel = useMemo(() => inProgress ? safeCancel : () => { /* do nothing */ }, [inProgress]); 37 | 38 | useKeyboardAction('Escape', finalCancel); 39 | 40 | const showInFolder = () => conversion.showInFolder(); 41 | 42 | return ( 43 |
44 | { 48 | conversion.copy(); 49 | }} 50 | retry={() => { 51 | conversion.retry(); 52 | }} 53 | showInFolder={showInFolder}/> 54 | 55 | 56 | 65 |
66 | ); 67 | }; 68 | 69 | export default EditorConversionView; 70 | -------------------------------------------------------------------------------- /renderer/components/editor/editor-preview.tsx: -------------------------------------------------------------------------------- 1 | import TrafficLights from '../traffic-lights'; 2 | import VideoPlayer from './video-player'; 3 | import Options from './options'; 4 | import useEditorWindowState from 'hooks/editor/use-editor-window-state'; 5 | 6 | const EditorPreview = () => { 7 | const {title = 'Editor'} = useEditorWindowState(); 8 | 9 | return ( 10 |
11 |
12 |
13 |
14 | 15 |
{title}
16 |
17 |
18 | 19 |
20 | 21 | 69 |
70 | ); 71 | }; 72 | 73 | export default EditorPreview; 74 | -------------------------------------------------------------------------------- /renderer/components/editor/index.tsx: -------------------------------------------------------------------------------- 1 | import useConversionIdContext from 'hooks/editor/use-conversion-id'; 2 | import useEditorWindowState from 'hooks/editor/use-editor-window-state'; 3 | import {useEditorWindowSizeEffect} from 'hooks/editor/use-window-size'; 4 | import {useEffect, useState} from 'react'; 5 | import EditorConversionView from './conversion'; 6 | import EditorPreview from './editor-preview'; 7 | import classNames from 'classnames'; 8 | 9 | const Editor = () => { 10 | const {conversionId, setConversionId} = useConversionIdContext(); 11 | const state = useEditorWindowState(); 12 | const [isConversionPreviewState, setIsConversionPreviewState] = useState(false); 13 | 14 | useEffect(() => { 15 | if (state.conversionId && !conversionId) { 16 | setConversionId(state.conversionId); 17 | } 18 | }, [state.conversionId]); 19 | 20 | useEditorWindowSizeEffect(isConversionPreviewState); 21 | 22 | const isTransitioning = Boolean(conversionId) !== isConversionPreviewState; 23 | 24 | const className = classNames('container', { 25 | transitioning: isTransitioning 26 | }); 27 | 28 | const onTransitionEnd = () => { 29 | setIsConversionPreviewState(Boolean(conversionId)); 30 | }; 31 | 32 | return ( 33 |
37 | { 38 | isConversionPreviewState ? 39 | : 40 | 41 | } 42 | 55 |
56 | ); 57 | }; 58 | 59 | export default Editor; 60 | -------------------------------------------------------------------------------- /renderer/components/editor/options/index.tsx: -------------------------------------------------------------------------------- 1 | import LeftOptions from './left'; 2 | import RightOptions from './right'; 3 | 4 | const Options = () => { 5 | return ( 6 |
7 | 8 | 9 | 24 |
25 | ); 26 | }; 27 | 28 | export default Options; 29 | -------------------------------------------------------------------------------- /renderer/components/editor/video-metadata-container.tsx: -------------------------------------------------------------------------------- 1 | import {createContainer} from 'unstated-next'; 2 | import {useRef, useState} from 'react'; 3 | import {useShowWindow} from '../../hooks/use-show-window'; 4 | 5 | const useVideoMetadata = () => { 6 | const videoRef = useRef(); 7 | 8 | const [width, setWidth] = useState(0); 9 | const [height, setHeight] = useState(0); 10 | const [hasAudio, setHasAudio] = useState(false); 11 | const [duration, setDuration] = useState(0); 12 | useShowWindow(duration !== 0); 13 | 14 | const setVideoRef = (video: HTMLVideoElement) => { 15 | videoRef.current = video; 16 | }; 17 | 18 | const videoProps = { 19 | onLoadedMetadata: () => { 20 | setWidth(videoRef.current?.videoWidth); 21 | setHeight(videoRef.current?.videoHeight); 22 | setDuration(videoRef.current?.duration); 23 | }, 24 | onLoadedData: () => { 25 | const hasAudio = (videoRef.current as any).webkitAudioDecodedByteCount > 0 || Boolean( 26 | (videoRef.current as any).audioTracks && 27 | (videoRef.current as any).audioTracks.length > 0 28 | ); 29 | 30 | if (!hasAudio) { 31 | videoRef.current.muted = true; 32 | } 33 | 34 | setHasAudio(hasAudio); 35 | } 36 | }; 37 | 38 | return { 39 | width, 40 | height, 41 | hasAudio, 42 | duration, 43 | setVideoRef, 44 | videoProps 45 | }; 46 | }; 47 | 48 | const VideoMetadataContainer = createContainer(useVideoMetadata); 49 | 50 | export default VideoMetadataContainer; 51 | 52 | -------------------------------------------------------------------------------- /renderer/components/editor/video-time-container.tsx: -------------------------------------------------------------------------------- 1 | import {createContainer} from 'unstated-next'; 2 | import {useRef, useState, useEffect} from 'react'; 3 | 4 | const useVideoTime = () => { 5 | const videoRef = useRef(); 6 | 7 | const [startTime, setStartTime] = useState(0); 8 | const [endTime, setEndTime] = useState(0); 9 | const [duration, setDuration] = useState(0); 10 | const [currentTime, setCurrentTime] = useState(0); 11 | 12 | const setVideoRef = (video: HTMLVideoElement) => { 13 | videoRef.current = video; 14 | }; 15 | 16 | const videoProps = { 17 | onLoadedMetadata: () => { 18 | if (duration === 0) { 19 | setDuration(videoRef.current?.duration); 20 | setEndTime(videoRef.current?.duration); 21 | } 22 | }, 23 | onEnded: () => { 24 | updateTime(startTime); 25 | } 26 | }; 27 | 28 | const updateTime = (time: number, ignoreElement = false) => { 29 | if (time >= endTime && !videoRef.current.paused) { 30 | videoRef.current.currentTime = startTime; 31 | setCurrentTime(startTime); 32 | } else { 33 | if (!ignoreElement) { 34 | videoRef.current.currentTime = time; 35 | } 36 | 37 | setCurrentTime(time); 38 | } 39 | }; 40 | 41 | const updateStartTime = (time: number) => { 42 | if (time < endTime) { 43 | videoRef.current.currentTime = time; 44 | setStartTime(time); 45 | setCurrentTime(time); 46 | } 47 | }; 48 | 49 | const updateEndTime = (time: number) => { 50 | if (time > startTime) { 51 | videoRef.current.currentTime = time; 52 | setEndTime(time); 53 | setCurrentTime(time); 54 | } 55 | }; 56 | 57 | useEffect(() => { 58 | if (!videoRef.current) { 59 | return; 60 | } 61 | 62 | const interval = setInterval(() => { 63 | updateTime(videoRef.current.currentTime ?? 0, true); 64 | }, 1000 / 30); 65 | 66 | return () => { 67 | clearInterval(interval); 68 | }; 69 | }, [startTime, endTime]); 70 | 71 | return { 72 | startTime, 73 | endTime, 74 | duration, 75 | currentTime, 76 | updateTime, 77 | updateStartTime, 78 | updateEndTime, 79 | setVideoRef, 80 | videoProps 81 | }; 82 | }; 83 | 84 | const VideoTimeContainer = createContainer(useVideoTime); 85 | 86 | export default VideoTimeContainer; 87 | -------------------------------------------------------------------------------- /renderer/components/editor/video.tsx: -------------------------------------------------------------------------------- 1 | import {useRef, useEffect} from 'react'; 2 | import VideoTimeContainer from './video-time-container'; 3 | import VideoMetadataContainer from './video-metadata-container'; 4 | import VideoControlsContainer from './video-controls-container'; 5 | import useEditorWindowState from 'hooks/editor/use-editor-window-state'; 6 | import {ipcRenderer as ipc} from 'electron-better-ipc'; 7 | 8 | const getVideoProps = (propsArray: Array, HTMLVideoElement>>) => { 9 | const handlers = new Map(); 10 | 11 | for (const props of propsArray) { 12 | for (const [key, handler] of Object.entries(props)) { 13 | if (!handlers.has(key)) { 14 | handlers.set(key, []); 15 | } 16 | 17 | handlers.get(key).push(handler); 18 | } 19 | } 20 | 21 | // eslint-disable-next-line unicorn/no-array-reduce 22 | return [...handlers.entries()].reduce((acc, [key, handlerList]) => ({ 23 | ...acc, 24 | [key]: () => { 25 | for (const handler of handlerList) { 26 | handler?.(); 27 | } 28 | } 29 | }), {}); 30 | }; 31 | 32 | const Video = () => { 33 | const videoRef = useRef(); 34 | const {filePath} = useEditorWindowState(); 35 | const src = `file://${filePath}`; 36 | 37 | const videoTimeContainer = VideoTimeContainer.useContainer(); 38 | const videoMetadataContainer = VideoMetadataContainer.useContainer(); 39 | const videoControlsContainer = VideoControlsContainer.useContainer(); 40 | 41 | useEffect(() => { 42 | videoTimeContainer.setVideoRef(videoRef.current); 43 | videoMetadataContainer.setVideoRef(videoRef.current); 44 | videoControlsContainer.setVideoRef(videoRef.current); 45 | }, []); 46 | 47 | const videoProps = getVideoProps([ 48 | videoTimeContainer.videoProps, 49 | videoMetadataContainer.videoProps, 50 | videoControlsContainer.videoProps 51 | ]); 52 | 53 | const onContextMenu = async () => { 54 | const video = videoRef.current; 55 | 56 | if (!video) { 57 | return; 58 | } 59 | 60 | const wasPaused = video.paused; 61 | 62 | if (!wasPaused) { 63 | await videoControlsContainer.pause(); 64 | } 65 | 66 | const {Menu} = require('electron-util').api; 67 | const menu = Menu.buildFromTemplate([{ 68 | label: 'Snapshot', 69 | click: () => { 70 | ipc.callMain('save-snapshot', video.currentTime); 71 | } 72 | }]); 73 | 74 | menu.popup({ 75 | callback: () => { 76 | if (!wasPaused) { 77 | videoControlsContainer.play(); 78 | } 79 | } 80 | }); 81 | }; 82 | 83 | return ( 84 |
85 |
94 | ); 95 | }; 96 | 97 | export default Video; 98 | -------------------------------------------------------------------------------- /renderer/components/exports/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo} from 'react'; 2 | 3 | import useExportsList from '../../hooks/exports/use-exports-list'; 4 | import Export from './export'; 5 | 6 | const Exports = () => { 7 | const {state = []} = useExportsList(); 8 | 9 | const exportList = useMemo(() => state.reverse(), [state]); 10 | 11 | return ( 12 |
13 | { 14 | exportList.map(id => ( 15 | 18 | )) 19 | } 20 | 25 |
26 | ); 27 | }; 28 | 29 | export default Exports; 30 | -------------------------------------------------------------------------------- /renderer/components/exports/progress.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | import {SpinnerIcon} from '../../vectors'; 5 | 6 | export const ProgressSpinner = () => ( 7 |
8 | 9 | 30 |
31 | ); 32 | 33 | export const Progress = ({percent}: {percent: number}) => { 34 | const circumference = 12 * 2 * Math.PI; 35 | const offset = circumference - (percent * circumference); 36 | 37 | return ( 38 | 39 | 40 | 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /renderer/components/icon-menu.tsx: -------------------------------------------------------------------------------- 1 | import {MenuItemConstructorOptions} from 'electron'; 2 | import React, {FunctionComponent, useRef} from 'react'; 3 | import {SvgProps} from 'vectors/svg'; 4 | 5 | type MenuProps = { 6 | onOpen: (options: {x: number; y: number}) => void; 7 | } | { 8 | template: MenuItemConstructorOptions[]; 9 | }; 10 | 11 | type IconMenuProps = SvgProps & MenuProps & { 12 | icon: FunctionComponent; 13 | fillParent?: boolean; 14 | }; 15 | 16 | const IconMenu: FunctionComponent = props => { 17 | const {icon: Icon, fillParent, ...iconProps} = props; 18 | const container = useRef(null); 19 | 20 | const openMenu = () => { 21 | const boundingRect = container.current.children[0].getBoundingClientRect(); 22 | const {bottom, left} = boundingRect; 23 | 24 | if ('onOpen' in props) { 25 | props.onOpen({ 26 | x: Math.round(left), 27 | y: Math.round(bottom) 28 | }); 29 | } else { 30 | const {api} = require('electron-util'); 31 | const menu = api.Menu.buildFromTemplate(props.template); 32 | menu.popup({ 33 | x: Math.round(left), 34 | y: Math.round(bottom) 35 | }); 36 | } 37 | }; 38 | 39 | return ( 40 |
41 | 42 | 49 |
50 | ); 51 | }; 52 | 53 | export default IconMenu; 54 | -------------------------------------------------------------------------------- /renderer/components/keyboard-number-input.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import {handleInputKeyPress} from '../utils/inputs'; 4 | 5 | class KeyboardNumberInput extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.inputRef = React.createRef(); 9 | } 10 | 11 | getRef = () => { 12 | return this.inputRef; 13 | }; 14 | 15 | render() { 16 | const {onChange, min, max, ...rest} = this.props; 17 | 18 | return ( 19 | 20 | ); 21 | } 22 | } 23 | 24 | KeyboardNumberInput.propTypes = { 25 | onKeyDown: PropTypes.elementType, 26 | min: PropTypes.number, 27 | max: PropTypes.number, 28 | onChange: PropTypes.elementType 29 | }; 30 | 31 | export default KeyboardNumberInput; 32 | -------------------------------------------------------------------------------- /renderer/components/preferences/categories/category.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Category extends React.Component { 5 | render() { 6 | return ( 7 |
8 | {this.props.children} 9 | 17 |
18 | ); 19 | } 20 | } 21 | 22 | Category.propTypes = { 23 | children: PropTypes.oneOfType([ 24 | PropTypes.arrayOf(PropTypes.node), 25 | PropTypes.node 26 | ]).isRequired 27 | }; 28 | 29 | export default Category; 30 | -------------------------------------------------------------------------------- /renderer/components/preferences/categories/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import {ipcRenderer as ipc} from 'electron-better-ipc'; 4 | 5 | import {connect, PreferencesContainer} from '../../../containers'; 6 | 7 | import General from './general'; 8 | import Plugins from './plugins'; 9 | 10 | const CATEGORIES = [ 11 | { 12 | name: 'general', 13 | Component: General 14 | }, { 15 | name: 'plugins', 16 | Component: Plugins 17 | } 18 | ]; 19 | 20 | class Categories extends React.Component { 21 | componentDidUpdate(previousProps) { 22 | if (!previousProps.isMounted && this.props.isMounted) { 23 | // Wait for the transitions to end 24 | setTimeout(async () => ipc.callMain('preferences-ready'), 300); 25 | } 26 | } 27 | 28 | render() { 29 | const {category} = this.props; 30 | 31 | const index = CATEGORIES.findIndex(({name}) => name === category); 32 | 33 | return ( 34 |
35 |
36 | { 37 | CATEGORIES.map( 38 | ({name, Component}) => ( 39 | 40 | ) 41 | ) 42 | } 43 | 56 |
57 | ); 58 | } 59 | } 60 | 61 | Categories.propTypes = { 62 | category: PropTypes.string, 63 | isMounted: PropTypes.bool 64 | }; 65 | 66 | export default connect( 67 | [PreferencesContainer], 68 | ({category, isMounted}) => ({category, isMounted}) 69 | )(Categories); 70 | -------------------------------------------------------------------------------- /renderer/components/preferences/item/button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Button = ({title, onClick, tabIndex}) => ( 5 | 29 | ); 30 | 31 | Button.propTypes = { 32 | title: PropTypes.string, 33 | onClick: PropTypes.func.isRequired, 34 | tabIndex: PropTypes.number.isRequired 35 | }; 36 | 37 | export default Button; 38 | -------------------------------------------------------------------------------- /renderer/components/preferences/item/color-picker.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | 5 | const ColorPicker = ({hasErrors, value, onChange}) => { 6 | const className = classNames('container', {'has-errors': hasErrors}); 7 | const handleChange = event => { 8 | const value = event.currentTarget.value.toUpperCase(); 9 | onChange(value.startsWith('#') ? value : `#${value}`); 10 | }; 11 | 12 | return ( 13 |
14 |
15 | 19 |
20 | { 26 | event.currentTarget.select(); 27 | }} 28 | /> 29 | 85 |
86 | ); 87 | }; 88 | 89 | ColorPicker.propTypes = { 90 | value: PropTypes.string, 91 | onChange: PropTypes.elementType, 92 | hasErrors: PropTypes.bool 93 | }; 94 | 95 | export default ColorPicker; 96 | -------------------------------------------------------------------------------- /renderer/components/window-header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class WindowHeader extends React.Component { 5 | render() { 6 | return ( 7 |
8 | {this.props.title} 9 | {this.props.children} 10 | 31 |
32 | ); 33 | } 34 | } 35 | 36 | WindowHeader.propTypes = { 37 | title: PropTypes.string, 38 | children: PropTypes.oneOfType([ 39 | PropTypes.arrayOf(PropTypes.node), 40 | PropTypes.node 41 | ]) 42 | }; 43 | 44 | export default WindowHeader; 45 | -------------------------------------------------------------------------------- /renderer/containers/config.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron'; 2 | import {Container} from 'unstated'; 3 | 4 | export default class ConfigContainer extends Container { 5 | remote = electron.remote || false; 6 | 7 | state = {selectedTab: 0}; 8 | 9 | setPlugin(pluginName) { 10 | const {InstalledPlugin} = this.remote.require('./plugins/plugin'); 11 | this.plugin = new InstalledPlugin(pluginName); 12 | this.config = this.plugin.config; 13 | this.validators = this.config.validators; 14 | this.validate(); 15 | this.setState({ 16 | validators: this.validators, 17 | values: this.config.store, 18 | pluginName 19 | }); 20 | } 21 | 22 | setEditService = (pluginName, serviceTitle) => { 23 | const {InstalledPlugin} = this.remote.require('./plugins/plugin'); 24 | this.plugin = new InstalledPlugin(pluginName); 25 | this.config = this.plugin.config; 26 | this.validators = this.config.validators.filter(({title}) => title === serviceTitle); 27 | this.validate(); 28 | this.setState({ 29 | validators: this.validators, 30 | values: this.config.store, 31 | pluginName, 32 | serviceTitle 33 | }); 34 | }; 35 | 36 | validate = () => { 37 | for (const validator of this.validators) { 38 | validator.validate(this.config.store); 39 | } 40 | }; 41 | 42 | closeWindow = () => this.remote.getCurrentWindow().close(); 43 | 44 | openConfig = () => this.plugin.openConfigInEditor(); 45 | 46 | viewOnGithub = () => this.plugin.viewOnGithub(); 47 | 48 | onChange = (key, value) => { 49 | if (value === undefined) { 50 | this.config.delete(key); 51 | } else { 52 | this.config.set(key, value); 53 | } 54 | 55 | this.validate(); 56 | this.setState({values: this.config.store}); 57 | }; 58 | 59 | selectTab = selectedTab => { 60 | this.setState({selectedTab}); 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /renderer/containers/cursor.js: -------------------------------------------------------------------------------- 1 | import {Container} from 'unstated'; 2 | 3 | export default class CursorContainer extends Container { 4 | state = { 5 | observers: [] 6 | }; 7 | 8 | setCursor = ({pageX, pageY}) => { 9 | this.setState({cursorX: pageX, cursorY: pageY}); 10 | for (const observer of this.state.observers) { 11 | observer({pageX, pageY}); 12 | } 13 | }; 14 | 15 | addCursorObserver = observer => { 16 | const {observers} = this.state; 17 | this.setState({observers: [observer, ...observers]}); 18 | }; 19 | 20 | removeCursorObserver = observer => { 21 | const {observers} = this.state; 22 | this.setState({observers: observers.filter(o => o !== observer)}); 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /renderer/containers/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Subscribe} from 'unstated'; 3 | 4 | import CropperContainer from './cropper'; 5 | import CursorContainer from './cursor'; 6 | import ActionBarContainer from './action-bar'; 7 | import PreferencesContainer from './preferences'; 8 | import ConfigContainer from './config'; 9 | 10 | export const connect = (containers, mapStateToProps, mapActionsToProps) => Component => props => ( 11 | 12 | { 13 | (...containers) => { 14 | const stateProps = mapStateToProps ? mapStateToProps(...containers.map(a => a.state)) : {}; 15 | const actionProps = mapActionsToProps ? mapActionsToProps(...containers) : {}; 16 | const componentProps = {...props, ...stateProps, ...actionProps}; 17 | 18 | return ; 19 | } 20 | } 21 | 22 | ); 23 | 24 | export { 25 | CropperContainer, 26 | CursorContainer, 27 | ActionBarContainer, 28 | PreferencesContainer, 29 | ConfigContainer 30 | }; 31 | -------------------------------------------------------------------------------- /renderer/hooks/dark-mode.tsx: -------------------------------------------------------------------------------- 1 | import {useState, useEffect} from 'react'; 2 | 3 | const useDarkMode = () => { 4 | const {darkMode} = require('electron-util'); 5 | const [isDarkMode, setIsDarkMode] = useState(darkMode.isEnabled); 6 | 7 | useEffect(() => { 8 | return darkMode.onChange(() => { 9 | setIsDarkMode(darkMode.isEnabled); 10 | }); 11 | }, []); 12 | 13 | return isDarkMode; 14 | }; 15 | 16 | export default useDarkMode; 17 | -------------------------------------------------------------------------------- /renderer/hooks/editor/use-conversion-id.tsx: -------------------------------------------------------------------------------- 1 | import {CreateExportOptions} from 'common/types'; 2 | import {ipcRenderer} from 'electron-better-ipc'; 3 | import {createContext, PropsWithChildren, useContext, useMemo, useState} from 'react'; 4 | 5 | const ConversionIdContext = createContext<{ 6 | conversionId: string; 7 | setConversionId: (id: string) => void; 8 | startConversion: (options: CreateExportOptions) => Promise; 9 | }>(undefined); 10 | 11 | let savedConversionId: string; 12 | 13 | export const ConversionIdContextProvider = (props: PropsWithChildren>) => { 14 | const [conversionId, setConversionId] = useState(); 15 | 16 | const startConversion = async (options: CreateExportOptions) => { 17 | const id = await ipcRenderer.callMain('create-export', options); 18 | setConversionId(id); 19 | }; 20 | 21 | const updateConversionId = (id: string) => { 22 | savedConversionId = savedConversionId || id; 23 | setConversionId(id || savedConversionId); 24 | }; 25 | 26 | const value = useMemo(() => ({ 27 | conversionId, 28 | setConversionId: updateConversionId, 29 | startConversion 30 | }), [conversionId, setConversionId]); 31 | 32 | return ( 33 | 34 | {props.children} 35 | 36 | ); 37 | }; 38 | 39 | const useConversionIdContext = () => useContext(ConversionIdContext); 40 | 41 | export default useConversionIdContext; 42 | -------------------------------------------------------------------------------- /renderer/hooks/editor/use-conversion.tsx: -------------------------------------------------------------------------------- 1 | import {ExportsRemoteState} from 'common/types'; 2 | import createRemoteStateHook from 'hooks/use-remote-state'; 3 | 4 | const useConversion = createRemoteStateHook('exports'); 5 | 6 | export type UseConversion = ReturnType; 7 | export type UseConversionState = UseConversion['state']; 8 | export default useConversion; 9 | -------------------------------------------------------------------------------- /renderer/hooks/editor/use-editor-options.tsx: -------------------------------------------------------------------------------- 1 | import {EditorOptionsRemoteState} from 'common/types'; 2 | import createRemoteStateHook from 'hooks/use-remote-state'; 3 | 4 | const useEditorOptions = createRemoteStateHook('editor-options', { 5 | formats: [], 6 | editServices: [], 7 | fpsHistory: { 8 | gif: 60, 9 | mp4: 60, 10 | av1: 60, 11 | webm: 60, 12 | apng: 60, 13 | hevc: 60 14 | } 15 | }); 16 | 17 | export type EditorOptionsState = ReturnType['state']; 18 | export default useEditorOptions; 19 | -------------------------------------------------------------------------------- /renderer/hooks/editor/use-editor-window-state.tsx: -------------------------------------------------------------------------------- 1 | import useWindowState from 'hooks/window-state'; 2 | import {EditorWindowState} from 'common/types'; 3 | 4 | const useEditorWindowState = () => useWindowState(); 5 | export default useEditorWindowState; 6 | -------------------------------------------------------------------------------- /renderer/hooks/editor/use-share-plugins.tsx: -------------------------------------------------------------------------------- 1 | import OptionsContainer from 'components/editor/options-container'; 2 | import {remote} from 'electron'; 3 | import {ipcRenderer} from 'electron-better-ipc'; 4 | import {useMemo} from 'react'; 5 | 6 | const useSharePlugins = () => { 7 | const { 8 | formats, 9 | format, 10 | sharePlugin, 11 | updateSharePlugin 12 | } = OptionsContainer.useContainer(); 13 | 14 | const menuOptions = useMemo(() => { 15 | const selectedFormat = formats.find(f => f.format === format); 16 | 17 | let onlyBuiltIn = true; 18 | const options = selectedFormat?.plugins?.map(plugin => { 19 | if (plugin.apps && plugin.apps.length > 0) { 20 | const subMenu = plugin.apps.map(app => ({ 21 | label: app.isDefault ? `${app.name} (default)` : app.name, 22 | type: 'radio', 23 | checked: sharePlugin.app?.url === app.url, 24 | value: { 25 | pluginName: plugin.pluginName, 26 | serviceTitle: plugin.title, 27 | app 28 | }, 29 | icon: remote.nativeImage.createFromDataURL(app.icon).resize({width: 16, height: 16}) 30 | })); 31 | 32 | if (plugin.apps[0].isDefault) { 33 | subMenu.splice(1, 0, {type: 'separator'} as any); 34 | } 35 | 36 | return { 37 | isBuiltIn: true, 38 | subMenu, 39 | value: { 40 | pluginName: plugin.pluginName, 41 | serviceTitle: plugin.title, 42 | app: plugin.apps[0] 43 | }, 44 | checked: sharePlugin.pluginName === plugin.pluginName, 45 | label: 'Open With…' 46 | }; 47 | } 48 | 49 | if (!plugin.pluginName.startsWith('_')) { 50 | onlyBuiltIn = false; 51 | } 52 | 53 | return { 54 | value: { 55 | pluginName: plugin.pluginName, 56 | serviceTitle: plugin.title 57 | }, 58 | checked: sharePlugin.pluginName === plugin.pluginName, 59 | label: plugin.title 60 | }; 61 | }); 62 | 63 | if (onlyBuiltIn) { 64 | options?.push({ 65 | separator: true 66 | } as any, { 67 | label: 'Get Plugins…', 68 | checked: false, 69 | click: () => { 70 | ipcRenderer.callMain('open-preferences', {category: 'plugins', tab: 'discover'}); 71 | } 72 | } as any); 73 | } 74 | 75 | return options ?? []; 76 | }, [formats, format, sharePlugin]); 77 | 78 | const label = sharePlugin?.app ? sharePlugin.app.name : sharePlugin?.serviceTitle; 79 | 80 | return {menuOptions, label, onChange: updateSharePlugin}; 81 | }; 82 | 83 | export default useSharePlugins; 84 | -------------------------------------------------------------------------------- /renderer/hooks/editor/use-window-size.tsx: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron'; 2 | import {useEffect, useRef} from 'react'; 3 | import {resizeKeepingCenter} from 'utils/window'; 4 | 5 | const CONVERSION_WIDTH = 370; 6 | const CONVERSION_HEIGHT = 392; 7 | const DEFAULT_EDITOR_WIDTH = 768; 8 | const DEFAULT_EDITOR_HEIGHT = 480; 9 | 10 | export const useEditorWindowSizeEffect = (isConversionWindowState: boolean) => { 11 | const previousWindowSizeRef = useRef<{width: number; height: number}>(); 12 | 13 | useEffect(() => { 14 | if (!previousWindowSizeRef.current) { 15 | previousWindowSizeRef.current = { 16 | width: DEFAULT_EDITOR_WIDTH, 17 | height: DEFAULT_EDITOR_HEIGHT 18 | }; 19 | return; 20 | } 21 | 22 | const window = remote.getCurrentWindow(); 23 | const bounds = window.getBounds(); 24 | 25 | if (isConversionWindowState) { 26 | previousWindowSizeRef.current = { 27 | width: bounds.width, 28 | height: bounds.height 29 | }; 30 | 31 | window.setBounds(resizeKeepingCenter(bounds, {width: CONVERSION_WIDTH, height: CONVERSION_HEIGHT}), true); 32 | window.resizable = false; 33 | window.fullScreenable = false; 34 | } else { 35 | window.resizable = true; 36 | window.fullScreenable = true; 37 | window.setBounds(resizeKeepingCenter(bounds, previousWindowSizeRef.current), true); 38 | } 39 | }, [isConversionWindowState]); 40 | }; 41 | -------------------------------------------------------------------------------- /renderer/hooks/exports/use-exports-list.tsx: -------------------------------------------------------------------------------- 1 | import {ExportsListRemoteState} from 'common/types'; 2 | import createRemoteStateHook from 'hooks/use-remote-state'; 3 | 4 | const useExportsList = createRemoteStateHook('exports-list'); 5 | 6 | export type UseExportsList = ReturnType; 7 | export type UseExportsListState = UseExportsList['state']; 8 | export default useExportsList; 9 | -------------------------------------------------------------------------------- /renderer/hooks/use-confirmation.tsx: -------------------------------------------------------------------------------- 1 | import {useCallback} from 'react'; 2 | 3 | interface UseConfirmationOptions { 4 | message: string; 5 | detail?: string; 6 | confirmButtonText: string; 7 | cancelButtonText?: string; 8 | } 9 | 10 | export const useConfirmation = ( 11 | callback: () => void, 12 | options: UseConfirmationOptions 13 | ) => { 14 | return useCallback(() => { 15 | const {dialog, remote} = require('electron-util').api; 16 | 17 | const buttonIndex = dialog.showMessageBoxSync(remote.getCurrentWindow(), { 18 | type: 'question', 19 | buttons: [ 20 | options.confirmButtonText, 21 | options.cancelButtonText ?? 'Cancel' 22 | ], 23 | defaultId: 0, 24 | cancelId: 1, 25 | message: options.message, 26 | detail: options.detail 27 | }); 28 | 29 | if (buttonIndex === 0) { 30 | callback(); 31 | } 32 | }, [callback]); 33 | }; 34 | -------------------------------------------------------------------------------- /renderer/hooks/use-current-window.tsx: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron'; 2 | 3 | export const useCurrentWindow = () => { 4 | return remote.getCurrentWindow(); 5 | }; 6 | -------------------------------------------------------------------------------- /renderer/hooks/use-keyboard-action.tsx: -------------------------------------------------------------------------------- 1 | import {DependencyList, useEffect, useMemo} from 'react'; 2 | 3 | export const useKeyboardAction = (keyOrFilter: string | ((key: string, eventType: string) => boolean), action: () => void, deps: DependencyList = []) => { 4 | const isArgFilter = typeof keyOrFilter === 'function'; 5 | const filter = useMemo(() => typeof keyOrFilter === 'function' ? keyOrFilter : (key: string) => key === keyOrFilter, [keyOrFilter]); 6 | 7 | useEffect(() => { 8 | const handler = (event: KeyboardEvent) => { 9 | if (filter(event.key, event.type)) { 10 | action(); 11 | } 12 | }; 13 | 14 | document.addEventListener('keyup', handler); 15 | if (isArgFilter) { 16 | document.addEventListener('keydown', handler); 17 | document.addEventListener('keypress', handler); 18 | } 19 | 20 | return () => { 21 | document.removeEventListener('keyup', handler); 22 | 23 | if (isArgFilter) { 24 | document.removeEventListener('keypress', handler); 25 | document.removeEventListener('keydown', handler); 26 | } 27 | }; 28 | }, [...deps, filter, action]); 29 | }; 30 | -------------------------------------------------------------------------------- /renderer/hooks/use-remote-state.tsx: -------------------------------------------------------------------------------- 1 | import {useState, useEffect, useRef} from 'react'; 2 | import {ipcRenderer} from 'electron-better-ipc'; 3 | import {RemoteState, RemoteStateHook} from '../common/types'; 4 | 5 | // TODO: Import these util exports from the `main/remote-states/utils` file once we figure out the correct TS configuration 6 | export const getChannelName = (name: string, action: string) => `kap-remote-state-${name}-${action}`; 7 | 8 | export const getChannelNames = (name: string) => ({ 9 | subscribe: getChannelName(name, 'subscribe'), 10 | getState: getChannelName(name, 'get-state'), 11 | callAction: getChannelName(name, 'call-action'), 12 | stateUpdated: getChannelName(name, 'state-updated') 13 | }); 14 | 15 | const createRemoteStateHook = ( 16 | name: string, 17 | initialState?: Callback extends RemoteState ? State : never 18 | ): (id?: string) => RemoteStateHook => { 19 | const channelNames = getChannelNames(name); 20 | 21 | return (id?: string) => { 22 | const [state, setState] = useState(initialState); 23 | const [isLoading, setIsLoading] = useState(true); 24 | const actionsRef = useRef({}); 25 | 26 | useEffect(() => { 27 | const cleanup = ipcRenderer.answerMain(channelNames.stateUpdated, (data: {id?: string; state: any}) => { 28 | if (data.id === id) { 29 | setState(data.state); 30 | } 31 | }); 32 | 33 | (async () => { 34 | const actionKeys = (await ipcRenderer.callMain(channelNames.subscribe, id)); 35 | 36 | // eslint-disable-next-line unicorn/no-array-reduce 37 | const actions = actionKeys.reduce((acc, key) => ({ 38 | ...acc, 39 | [key]: async (...data: any) => ipcRenderer.callMain(channelNames.callAction, {key, data, id}) 40 | }), {}); 41 | 42 | const getState = async () => { 43 | const newState = (await ipcRenderer.callMain(channelNames.getState, id)); 44 | setState(newState); 45 | }; 46 | 47 | actionsRef.current = { 48 | ...actions, 49 | refreshState: getState 50 | }; 51 | 52 | await getState(); 53 | setIsLoading(false); 54 | })(); 55 | 56 | return cleanup; 57 | }, []); 58 | 59 | return { 60 | ...actionsRef.current, 61 | isLoading, 62 | state 63 | }; 64 | }; 65 | }; 66 | 67 | export default createRemoteStateHook; 68 | -------------------------------------------------------------------------------- /renderer/hooks/use-show-window.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react'; 2 | import {ipcRenderer} from 'electron-better-ipc'; 3 | 4 | export const useShowWindow = (show: boolean) => { 5 | useEffect(() => { 6 | if (show) { 7 | ipcRenderer.callMain('kap-window-mount'); 8 | } 9 | }, [show]); 10 | }; 11 | -------------------------------------------------------------------------------- /renderer/hooks/window-state.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, useContext, useState, useEffect, ReactNode} from 'react'; 2 | import {ipcRenderer as ipc} from 'electron-better-ipc'; 3 | 4 | const WindowStateContext = createContext(undefined); 5 | 6 | export const WindowStateProvider = (props: {children: ReactNode}) => { 7 | const [windowState, setWindowState] = useState(); 8 | 9 | useEffect(() => { 10 | ipc.callMain('kap-window-state').then(setWindowState); 11 | 12 | return ipc.answerMain('kap-window-state', (newState: any) => { 13 | setWindowState(newState); 14 | }); 15 | }, []); 16 | 17 | return ( 18 | 19 | {props.children} 20 | 21 | ); 22 | }; 23 | 24 | // Should not be used directly 25 | // Each page should export its own typed hook 26 | // eslint-disable-next-line @typescript-eslint/comma-dangle 27 | const useWindowState = () => useContext(WindowStateContext); 28 | 29 | export default useWindowState; 30 | -------------------------------------------------------------------------------- /renderer/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /renderer/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = (nextConfig) => { 4 | return Object.assign({}, nextConfig, { 5 | webpack(config, options) { 6 | config.module.rules.push({ 7 | test: /\.+(js|jsx|mjs|ts|tsx)$/, 8 | loader: options.defaultLoaders.babel, 9 | include: [ 10 | path.join(__dirname, '..', 'main', 'common'), 11 | path.join(__dirname, '..', 'main', 'remote-states', 'use-remote-state.ts') 12 | ] 13 | }); 14 | 15 | config.target = 'electron-renderer'; 16 | config.devtool = 'cheap-module-source-map'; 17 | 18 | if (typeof nextConfig.webpack === 'function') { 19 | return nextConfig.webpack(config, options); 20 | } 21 | 22 | return config; 23 | } 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /renderer/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import {AppProps} from 'next/app'; 2 | import {useState, useEffect} from 'react'; 3 | import useDarkMode from '../hooks/dark-mode'; 4 | import GlobalStyles from '../utils/global-styles'; 5 | import SentryErrorBoundary from '../utils/sentry-error-boundary'; 6 | import {WindowStateProvider} from '../hooks/window-state'; 7 | import classNames from 'classnames'; 8 | 9 | const Kap = (props: AppProps) => { 10 | const [isMounted, setIsMounted] = useState(false); 11 | 12 | useEffect(() => { 13 | setIsMounted(true); 14 | }, []); 15 | 16 | if (!isMounted) { 17 | return null; 18 | } 19 | 20 | return ; 21 | }; 22 | 23 | const MainApp = ({Component, pageProps}: AppProps) => { 24 | const isDarkMode = useDarkMode(); 25 | const className = classNames('cover-window', {dark: isDarkMode}); 26 | 27 | return ( 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | ); 37 | }; 38 | 39 | export default Kap; 40 | -------------------------------------------------------------------------------- /renderer/pages/config.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Provider} from 'unstated'; 3 | import {ipcRenderer as ipc} from 'electron-better-ipc'; 4 | 5 | import {ConfigContainer} from '../containers'; 6 | import Config from '../components/config'; 7 | import WindowHeader from '../components/window-header'; 8 | 9 | const configContainer = new ConfigContainer(); 10 | 11 | export default class ConfigPage extends React.Component { 12 | state = {title: ''}; 13 | 14 | componentDidMount() { 15 | ipc.answerMain('plugin', pluginName => { 16 | configContainer.setPlugin(pluginName); 17 | this.setState({title: pluginName.replace(/^kap-/, '')}); 18 | }); 19 | 20 | ipc.answerMain('edit-service', ({pluginName, serviceTitle}) => { 21 | configContainer.setEditService(pluginName, serviceTitle); 22 | this.setState({title: serviceTitle}); 23 | }); 24 | } 25 | 26 | render() { 27 | const {title} = this.state; 28 | 29 | return ( 30 |
31 |
32 | 33 | 34 | 35 | 36 |
37 | 73 |
74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /renderer/pages/editor.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | // Import EditorPreview from '../components/editor/editor-preview'; 3 | import combineUnstatedContainers from '../utils/combine-unstated-containers'; 4 | import VideoMetadataContainer from '../components/editor/video-metadata-container'; 5 | import VideoTimeContainer from '../components/editor/video-time-container'; 6 | import VideoControlsContainer from '../components/editor/video-controls-container'; 7 | import OptionsContainer from '../components/editor/options-container'; 8 | import useEditorWindowState from 'hooks/editor/use-editor-window-state'; 9 | import {ConversionIdContextProvider} from 'hooks/editor/use-conversion-id'; 10 | import Editor from 'components/editor'; 11 | 12 | const ContainerProvider = combineUnstatedContainers([ 13 | OptionsContainer, 14 | VideoMetadataContainer, 15 | VideoTimeContainer, 16 | VideoControlsContainer 17 | ]) as any; 18 | 19 | const EditorPage = () => { 20 | const args = useEditorWindowState(); 21 | 22 | if (!args) { 23 | return null; 24 | } 25 | 26 | return ( 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 86 |
87 | ); 88 | }; 89 | 90 | export default EditorPage; 91 | -------------------------------------------------------------------------------- /renderer/pages/exports.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import WindowHeader from '../components/window-header'; 4 | import Exports from '../components/exports'; 5 | 6 | const ExportsPage = () => ( 7 |
8 | 9 | 10 | 23 |
24 | ); 25 | 26 | export default ExportsPage; 27 | -------------------------------------------------------------------------------- /renderer/public/static/all-the-things.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/renderer/public/static/all-the-things.png -------------------------------------------------------------------------------- /renderer/public/static/kap-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/renderer/public/static/kap-icon.png -------------------------------------------------------------------------------- /renderer/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "next-env.d.ts", 5 | "**/*.ts", 6 | "**/*.tsx", 7 | "**/*.js" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "preserveSymlinks": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "preserve", 21 | "downlevelIteration": true, 22 | "baseUrl": ".", 23 | "paths": { 24 | "utils/*": ["./utils/*"], 25 | "components/*": ["./components/*"], 26 | "containers/*": ["./containers/*"], 27 | "hooks/*": ["./hooks/*"], 28 | "common/*": ["./common/*"], 29 | "vectors": ["./vectors"] 30 | } 31 | }, 32 | "exclude": [ 33 | "node_modules", 34 | "common-remote-states" 35 | ], 36 | "include": [ 37 | "next-env.d.ts", 38 | "**/*.ts", 39 | "**/*.tsx" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /renderer/utils/combine-unstated-containers.tsx: -------------------------------------------------------------------------------- 1 | import React, {FunctionComponent, PropsWithChildren} from 'react'; 2 | import {Container} from 'unstated-next'; 3 | 4 | type ContainerOrWithInitialState = Container | [Container, T]; 5 | 6 | const combineUnstatedContainers = (containers: ContainerOrWithInitialState[]) => ({children}: PropsWithChildren>) => { 7 | // eslint-disable-next-line unicorn/no-array-reduce 8 | return containers.reduce( 9 | (tree, ContainerOrWithInitialState) => { 10 | if (Array.isArray(ContainerOrWithInitialState)) { 11 | const [Container, initialState] = ContainerOrWithInitialState; 12 | return {tree}; 13 | } 14 | 15 | return {tree}; 16 | }, 17 | // @ts-expect-error 18 | children 19 | ); 20 | }; 21 | 22 | export default combineUnstatedContainers; 23 | -------------------------------------------------------------------------------- /renderer/utils/format-time.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | const formatTime = (time, options) => { 4 | options = { 5 | showMilliseconds: false, 6 | ...options 7 | }; 8 | 9 | const durationFormatted = options.extra ? 10 | ` (${format(options.extra, options)})` : 11 | ''; 12 | 13 | return `${format(time, options)}${durationFormatted}`; 14 | }; 15 | 16 | const format = (time, {showMilliseconds} = {}) => { 17 | const formatString = `${time >= 60 * 60 ? 'hh:m' : ''}m:ss${showMilliseconds ? '.SS' : ''}`; 18 | 19 | return moment().startOf('day').millisecond(time * 1000).format(formatString); 20 | }; 21 | 22 | export default formatTime; 23 | -------------------------------------------------------------------------------- /renderer/utils/sentry-error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as Sentry from '@sentry/browser'; 3 | import electron from 'electron'; 4 | import type {api as Api, is as Is} from 'electron-util'; 5 | 6 | const SENTRY_PUBLIC_DSN = 'https://2dffdbd619f34418817f4db3309299ce@sentry.io/255536'; 7 | 8 | class SentryErrorBoundary extends React.Component<{children: React.ReactNode}> { 9 | constructor(props) { 10 | super(props); 11 | const {settings} = electron.remote.require('./common/settings'); 12 | // Done in-line because this is used in _app 13 | const {is, api} = require('electron-util') as { 14 | api: typeof Api; 15 | is: typeof Is; 16 | }; 17 | 18 | if (!is.development && settings.get('allowAnalytics')) { 19 | const release = `${api.app.name}@${api.app.getVersion()}`.toLowerCase(); 20 | Sentry.init({dsn: SENTRY_PUBLIC_DSN, release}); 21 | } 22 | } 23 | 24 | componentDidCatch(error, errorInfo) { 25 | console.log(error, errorInfo); 26 | Sentry.configureScope(scope => { 27 | for (const [key, value] of Object.entries(errorInfo)) { 28 | scope.setExtra(key, value); 29 | } 30 | }); 31 | 32 | Sentry.captureException(error); 33 | 34 | // This is needed to render errors correctly in development / production 35 | super.componentDidCatch(error, errorInfo); 36 | } 37 | 38 | render() { 39 | return this.props.children; 40 | } 41 | } 42 | 43 | export default SentryErrorBoundary; 44 | -------------------------------------------------------------------------------- /renderer/utils/window.ts: -------------------------------------------------------------------------------- 1 | 2 | export const resizeKeepingCenter = ( 3 | bounds: Electron.Rectangle, 4 | newSize: {width: number; height: number} 5 | ): Electron.Rectangle => { 6 | const cx = Math.round(bounds.x + (bounds.width / 2)); 7 | const cy = Math.round(bounds.y + (bounds.height / 2)); 8 | 9 | return { 10 | x: Math.round(cx - (newSize.width / 2)), 11 | y: Math.round(cy - (newSize.height / 2)), 12 | width: newSize.width, 13 | height: newSize.height 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /renderer/vectors/applications.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | // eslint-disable-next-line unicorn/prevent-abbreviations 5 | const ApplicationsIcon = props => ( 6 | 7 | 8 | 9 | ); 10 | 11 | export default ApplicationsIcon; 12 | -------------------------------------------------------------------------------- /renderer/vectors/back-plain.tsx: -------------------------------------------------------------------------------- 1 | import React, {FunctionComponent} from 'react'; 2 | import Svg, {SvgProps} from './svg'; 3 | 4 | const BackPlainIcon: FunctionComponent = props => ( 5 | 6 | 7 | 8 | 9 | ); 10 | 11 | export default BackPlainIcon; 12 | -------------------------------------------------------------------------------- /renderer/vectors/back.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const BackIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default BackIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/cancel.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const CancelIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default CancelIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/crop.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const CropIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default CropIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/dropdown-arrow.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const DropdownArrowIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default DropdownArrowIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/edit.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const EditIcon = props => ( 5 | 6 | 7 | 8 | 9 | ); 10 | 11 | export default EditIcon; 12 | -------------------------------------------------------------------------------- /renderer/vectors/error.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const ErrorIcon = props => ( 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | 12 | export default ErrorIcon; 13 | -------------------------------------------------------------------------------- /renderer/vectors/exit-fullscreen.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const ExitFullscreenIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default ExitFullscreenIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/fullscreen.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const FullscrenIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default FullscrenIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/gear.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const GearIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default GearIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/help.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const HelpIcon = props => ( 5 | 6 | 7 | 8 | 9 | ); 10 | 11 | export default HelpIcon; 12 | -------------------------------------------------------------------------------- /renderer/vectors/index.js: -------------------------------------------------------------------------------- 1 | import ApplicationsIcon from './applications'; 2 | import BackIcon from './back'; 3 | import CropIcon from './crop'; 4 | import DropdownArrowIcon from './dropdown-arrow'; 5 | import FullscreenIcon from './fullscreen'; 6 | import LinkIcon from './link'; 7 | import SwapIcon from './swap'; 8 | import ExitFullscreenIcon from './exit-fullscreen'; 9 | import SettingsIcon from './settings'; 10 | import TuneIcon from './tune'; 11 | import PluginsIcon from './plugins'; 12 | import GearIcon from './gear'; 13 | import SpinnerIcon from './spinner'; 14 | import MoreIcon from './more'; 15 | import PlayIcon from './play'; 16 | import PauseIcon from './pause'; 17 | import VolumeHighIcon from './volume-high'; 18 | import VolumeOffIcon from './volume-off'; 19 | import CancelIcon from './cancel'; 20 | import TooltipIcon from './tooltip'; 21 | import EditIcon from './edit'; 22 | import ErrorIcon from './error'; 23 | import OpenConfigIcon from './open-config'; 24 | import OpenOnGithubIcon from './open-on-github'; 25 | import HelpIcon from './help'; 26 | import BackPlainIcon from './back-plain'; 27 | 28 | export { 29 | ApplicationsIcon, 30 | BackIcon, 31 | CropIcon, 32 | DropdownArrowIcon, 33 | FullscreenIcon, 34 | LinkIcon, 35 | SwapIcon, 36 | ExitFullscreenIcon, 37 | SettingsIcon, 38 | TuneIcon, 39 | PluginsIcon, 40 | GearIcon, 41 | SpinnerIcon, 42 | MoreIcon, 43 | PlayIcon, 44 | PauseIcon, 45 | VolumeHighIcon, 46 | VolumeOffIcon, 47 | CancelIcon, 48 | TooltipIcon, 49 | EditIcon, 50 | ErrorIcon, 51 | OpenConfigIcon, 52 | OpenOnGithubIcon, 53 | HelpIcon, 54 | BackPlainIcon 55 | }; 56 | -------------------------------------------------------------------------------- /renderer/vectors/link.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const LinkIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default LinkIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/more.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const MoreIcon = props => ( 5 | 6 | 7 | 8 | 9 | ); 10 | 11 | export default MoreIcon; 12 | -------------------------------------------------------------------------------- /renderer/vectors/open-config.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const OpenConfigIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default OpenConfigIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/open-on-github.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const OpenOnGithubIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default OpenOnGithubIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/pause.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const PauseIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default PauseIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/play.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const PlayIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default PlayIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/plugins.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const PluginsIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default PluginsIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/settings.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const SettingsIcon = props => ( 5 | 6 | 7 | 8 | 9 | ); 10 | 11 | export default SettingsIcon; 12 | -------------------------------------------------------------------------------- /renderer/vectors/spinner.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | const SpinnerIcon = ({stroke = 'var(--kap)'}) => ( 5 | 6 | 7 | 29 | 30 | ); 31 | 32 | SpinnerIcon.propTypes = { 33 | stroke: PropTypes.string 34 | }; 35 | 36 | export default SpinnerIcon; 37 | -------------------------------------------------------------------------------- /renderer/vectors/svg.tsx: -------------------------------------------------------------------------------- 1 | import React, {FunctionComponent} from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import {handleKeyboardActivation} from '../utils/inputs'; 5 | 6 | const defaultProps: SvgProps = { 7 | fill: 'var(--icon-color)', 8 | activeFill: 'var(--kap)', 9 | hoverFill: 'var(--icon-hover-color)', 10 | size: '24px', 11 | active: false, 12 | viewBox: '0 0 24 24', 13 | tabIndex: -1 14 | }; 15 | 16 | const stopPropagation = event => { 17 | event.stopPropagation(); 18 | }; 19 | 20 | const Svg: FunctionComponent = props => { 21 | const { 22 | fill, 23 | size, 24 | activeFill, 25 | hoverFill, 26 | active, 27 | onClick, 28 | children, 29 | viewBox, 30 | shadow, 31 | tabIndex, 32 | isMenu 33 | } = { 34 | ...defaultProps, 35 | ...props 36 | }; 37 | 38 | const className = classNames({active, shadow, focusable: tabIndex >= 0}); 39 | 40 | return ( 41 |
= 0 ? handleKeyboardActivation(onClick, {isMenu}) : undefined}> 42 | 48 | {children} 49 | 50 | 92 |
93 | ); 94 | }; 95 | 96 | export interface SvgProps { 97 | fill?: string; 98 | size?: string; 99 | activeFill?: string; 100 | hoverFill?: string; 101 | active?: boolean; 102 | viewBox?: string; 103 | onClick?: () => void; 104 | shadow?: boolean; 105 | tabIndex?: number; 106 | isMenu?: boolean; 107 | } 108 | 109 | export default Svg; 110 | -------------------------------------------------------------------------------- /renderer/vectors/swap.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const SwapIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default SwapIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/tooltip.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const TooltipIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default TooltipIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/tune.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const TuneIcon = props => ( 5 | 6 | 7 | 8 | 9 | ); 10 | 11 | export default TuneIcon; 12 | -------------------------------------------------------------------------------- /renderer/vectors/volume-high.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const VolumeHighIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default VolumeHighIcon; 11 | -------------------------------------------------------------------------------- /renderer/vectors/volume-off.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg from './svg'; 3 | 4 | const VolumeOffIcon = props => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default VolumeOffIcon; 11 | -------------------------------------------------------------------------------- /static/menubar-loading/loading_00000Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00000Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00000Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00000Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00001Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00001Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00001Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00001Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00002Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00002Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00002Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00002Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00003Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00003Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00003Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00003Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00004Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00004Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00004Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00004Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00005Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00005Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00005Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00005Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00006Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00006Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00006Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00006Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00007Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00007Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00007Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00007Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00008Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00008Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00008Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00008Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00009Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00009Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00009Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00009Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00010Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00010Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00010Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00010Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00011Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00011Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00011Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00011Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00012Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00012Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00012Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00012Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00013Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00013Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00013Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00013Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00014Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00014Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00014Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00014Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00015Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00015Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00015Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00015Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00016Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00016Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00016Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00016Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00017Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00017Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00017Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00017Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00018Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00018Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00018Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00018Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00019Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00019Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00019Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00019Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00020Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00020Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00020Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00020Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00021Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00021Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00021Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00021Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00022Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00022Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00022Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00022Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00023Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00023Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00023Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00023Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00024Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00024Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00024Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00024Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00025Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00025Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00025Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00025Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00026Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00026Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00026Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00026Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00027Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00027Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00027Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00027Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00028Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00028Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00028Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00028Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00029Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00029Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00029Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00029Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00030Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00030Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00030Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00030Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00031Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00031Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00031Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00031Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00032Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00032Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00032Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00032Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00033Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00033Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00033Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00033Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00034Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00034Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00034Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00034Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00035Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00035Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00035Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00035Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00036Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00036Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00036Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00036Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00037Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00037Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00037Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00037Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00038Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00038Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00038Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00038Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00039Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00039Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00039Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00039Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00040Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00040Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00040Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00040Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00041Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00041Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00041Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00041Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00042Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00042Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00042Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00042Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00043Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00043Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00043Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00043Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00044Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00044Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00044Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00044Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00045Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00045Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00045Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00045Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00046Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00046Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00046Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00046Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00047Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00047Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00047Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00047Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00048Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00048Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00048Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00048Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00049Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00049Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00049Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00049Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00050Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00050Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00050Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00050Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00051Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00051Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00051Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00051Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00052Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00052Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00052Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00052Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00053Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00053Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00053Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00053Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00054Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00054Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00054Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00054Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00055Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00055Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00055Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00055Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00056Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00056Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00056Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00056Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00057Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00057Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00057Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00057Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00058Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00058Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00058Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00058Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00059Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00059Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00059Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00059Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00060Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00060Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00060Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00060Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00061Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00061Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00061Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00061Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00062Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00062Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00062Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00062Template@2x.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00063Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00063Template.png -------------------------------------------------------------------------------- /static/menubar-loading/loading_00063Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubar-loading/loading_00063Template@2x.png -------------------------------------------------------------------------------- /static/menubarDefaultTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubarDefaultTemplate.png -------------------------------------------------------------------------------- /static/menubarDefaultTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/menubarDefaultTemplate@2x.png -------------------------------------------------------------------------------- /static/pauseTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/pauseTemplate.png -------------------------------------------------------------------------------- /static/pauseTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/static/pauseTemplate@2x.png -------------------------------------------------------------------------------- /test/fixtures/corrupt.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/test/fixtures/corrupt.mp4 -------------------------------------------------------------------------------- /test/fixtures/incomplete.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/test/fixtures/incomplete.mp4 -------------------------------------------------------------------------------- /test/fixtures/input.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/test/fixtures/input.mp4 -------------------------------------------------------------------------------- /test/fixtures/input@2x.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wulkano/Kap/c42692fa63ac71ed192e01684beb78a1b864aa88/test/fixtures/input@2x.mp4 -------------------------------------------------------------------------------- /test/helpers/assertions.ts: -------------------------------------------------------------------------------- 1 | 2 | export const almostEquals = (actual: number, expected: number, threshold = 0.5) => { 3 | return Math.abs(actual - expected) <= threshold ? true : ` 4 | Actual: ${actual} 5 | Expected: ${expected} 6 | Threshold: ${threshold} 7 | Diff: ${Math.abs(actual - expected)} 8 | `; 9 | }; 10 | -------------------------------------------------------------------------------- /test/helpers/mocks.ts: -------------------------------------------------------------------------------- 1 | import moduleAlias from 'module-alias'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | 5 | export const mockModule = (name: string) => { 6 | const mockModulePathTypescript = path.resolve(__dirname, '..', 'mocks', `${name}.ts`); 7 | const mockModulePath = path.resolve(__dirname, '..', 'mocks', `${name}.js`); 8 | 9 | const mockPath = [ 10 | mockModulePathTypescript, 11 | mockModulePath 12 | ].find(p => fs.existsSync(p)); 13 | 14 | if (!mockPath) { 15 | throw new Error(`Missing mock implementation at ${mockModulePath}`.replace('js', '(ts|js)')); 16 | } 17 | 18 | moduleAlias.addAlias(name, mockPath); 19 | return require(mockPath); 20 | }; 21 | 22 | export const mockImport = (importPath: string, mock: string) => { 23 | const mockModulePathTypescript = path.resolve(__dirname, '..', 'mocks', `${mock}.ts`); 24 | const mockModulePath = path.resolve(__dirname, '..', 'mocks', `${mock}.js`); 25 | 26 | const mockPath = [ 27 | mockModulePathTypescript, 28 | mockModulePath 29 | ].find(p => fs.existsSync(p)); 30 | 31 | if (!mockPath) { 32 | throw new Error(`Missing mock implementation at ${mockModulePath}`.replace('js', '(ts|js)')); 33 | } 34 | 35 | moduleAlias.addAlias(importPath, mockPath); 36 | return require(mockPath); 37 | }; 38 | -------------------------------------------------------------------------------- /test/helpers/video-utils.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import execa from 'execa'; 3 | 4 | const ffmpegPath = require('ffmpeg-static'); 5 | 6 | const getDuration = (text: string): number => { 7 | const durationString = /Duration: ([\d:.]*)/.exec(text)?.[1]; 8 | return moment.duration(durationString).asSeconds(); 9 | }; 10 | 11 | const getEncoding = (text: string) => /Stream.*Video: (.*?)[, ]/.exec(text)?.[1]; 12 | 13 | const getFps = (text: string) => { 14 | const fpsString = /([\d.]*) fps/.exec(text)?.[1]; 15 | return Number.parseFloat(fpsString!); 16 | }; 17 | 18 | const getSize = (text: string) => { 19 | const sizeText = /Video:.*?, (\d*x\d*)/.exec(text)?.[1]!; 20 | const parts = sizeText.split('x'); 21 | return { 22 | width: Number.parseFloat(parts[0]), 23 | height: Number.parseFloat(parts[1]) 24 | }; 25 | }; 26 | 27 | const getHasAudio = (text: string) => /Stream #.*: Audio/.test(text); 28 | 29 | // @ts-expect-error 30 | export const getVideoMetadata = async (path: string): Promise<{ 31 | duration: number; 32 | encoding: string; 33 | fps: number; 34 | size: {width: number; height: number}; 35 | hasAudio: boolean; 36 | }> => { 37 | try { 38 | await execa(ffmpegPath, ['-i', path]); 39 | } catch (error) { 40 | const {stderr} = error as any; 41 | return { 42 | duration: getDuration(stderr), 43 | encoding: getEncoding(stderr)!, 44 | fps: getFps(stderr)!, 45 | size: getSize(stderr) as {width: number; height: number}, 46 | hasAudio: getHasAudio(stderr)! 47 | }; 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /test/mocks/analytics.ts: -------------------------------------------------------------------------------- 1 | 2 | export const initializeAnalytics = () => {}; 3 | export const track = () => {}; 4 | -------------------------------------------------------------------------------- /test/mocks/dialog.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | let dialogState: any; 4 | let dialogResolve: any; 5 | let waitForDialogResolve: any; 6 | 7 | export const showDialog = sinon.fake(async (options: any) => new Promise(resolve => { 8 | dialogState = options; 9 | dialogResolve = resolve; 10 | 11 | if (waitForDialogResolve) { 12 | waitForDialogResolve(options); 13 | } 14 | 15 | waitForDialogResolve = undefined; 16 | })); 17 | 18 | const resolve = (result: any) => { 19 | if (dialogResolve) { 20 | dialogResolve(result); 21 | } 22 | 23 | dialogResolve = undefined; 24 | dialogState = undefined; 25 | }; 26 | 27 | export const fakeAction = async (index: any) => { 28 | const button = dialogState.buttons[index]; 29 | const action = button?.action; 30 | let wasCalled = false; 31 | 32 | if (action) { 33 | await action(resolve, (newState: any) => { 34 | wasCalled = true; 35 | dialogState = newState; 36 | }); 37 | 38 | if (!wasCalled) { 39 | resolve(index); 40 | } 41 | } else { 42 | resolve(index); 43 | } 44 | }; 45 | 46 | export const getCurrentState = () => dialogState; 47 | 48 | export const waitForDialog = async () => new Promise(resolve => { 49 | waitForDialogResolve = resolve; 50 | }); 51 | -------------------------------------------------------------------------------- /test/mocks/electron-store.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | const mocks: Record = {}; 4 | const store: Record = {}; 5 | 6 | const getMock = sinon.fake( 7 | (key: string, defaultValue: any) => mocks[key] ?? store[key] ?? defaultValue 8 | ); 9 | 10 | const setMock = sinon.fake( 11 | (key: string, value: any) => { 12 | store[key] = value; 13 | } 14 | ); 15 | 16 | const deleteMock = sinon.fake( 17 | (key: string) => { 18 | delete store[key]; 19 | } 20 | ); 21 | 22 | const clearMock = sinon.fake( 23 | () => { 24 | for (const key of Object.keys(store)) { 25 | delete store[key]; 26 | } 27 | } 28 | ); 29 | 30 | export default class Store { 31 | get = getMock; 32 | set = setMock; 33 | delete = deleteMock; 34 | clear = clearMock; 35 | 36 | get store() { 37 | return { 38 | ...store, 39 | ...mocks 40 | }; 41 | } 42 | 43 | static mockGet = (key: string, result: any) => { 44 | mocks[key] = result; 45 | }; 46 | 47 | static clearMocks = () => { 48 | for (const key of Object.keys(mocks)) { 49 | delete mocks[key]; 50 | } 51 | }; 52 | 53 | static mocks = { 54 | get: getMock, 55 | set: setMock, 56 | delete: deleteMock, 57 | clear: clearMock 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /test/mocks/electron.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import tempy from 'tempy'; 3 | import path from 'path'; 4 | 5 | const temporaryDir = tempy.directory(); 6 | 7 | process.env.TZ = 'America/New_York'; 8 | (process.versions as any).chrome = ''; 9 | 10 | export const app = { 11 | getPath: (name: string) => path.resolve(temporaryDir, name), 12 | isPackaged: false, 13 | getVersion: '' 14 | }; 15 | 16 | export const shell = { 17 | showItemInFolder: sinon.fake() 18 | }; 19 | 20 | export const clipboard = { 21 | writeText: sinon.fake() 22 | }; 23 | 24 | export const remote = {}; 25 | -------------------------------------------------------------------------------- /test/mocks/plugins.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import {Mutable, PartialDeep} from 'type-fest'; 3 | import type {Plugins} from '../../main/plugins'; 4 | 5 | export const plugins: PartialDeep> = { 6 | recordingPlugins: [], 7 | sharePlugins: [], 8 | editPlugins: [] 9 | }; 10 | -------------------------------------------------------------------------------- /test/mocks/sentry.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | export const isSentryEnabled = true; 4 | 5 | export default { 6 | captureException: sinon.fake() 7 | }; 8 | -------------------------------------------------------------------------------- /test/mocks/service-context.ts: -------------------------------------------------------------------------------- 1 | 2 | export class ShareServiceContext {} 3 | export class RecordServiceContext {} 4 | export class EditServiceContext {} 5 | -------------------------------------------------------------------------------- /test/mocks/settings.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | const mocks: Record = {}; 4 | 5 | const mockGet = sinon.fake((key: string, defaultValue: any) => mocks[key] || defaultValue); 6 | 7 | export const settings = { 8 | get: mockGet, 9 | set: sinon.fake(), 10 | delete: sinon.fake(), 11 | setMock: (key: string, value: any) => { 12 | mocks[key] = value; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /test/mocks/video.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | const mocks = { 4 | open: sinon.fake(), 5 | constructor: sinon.fake(), 6 | getOrCreate: sinon.fake(() => new Video()) 7 | }; 8 | 9 | export default class Video { 10 | static getOrCreate = mocks.getOrCreate; 11 | openEditorWindow = mocks.open; 12 | 13 | constructor(...args: any[]) { 14 | mocks.constructor(...args); 15 | } 16 | 17 | static mocks = mocks; 18 | } 19 | -------------------------------------------------------------------------------- /test/mocks/window-manager.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import {SetOptional} from 'type-fest'; 3 | 4 | import type {WindowManager} from '../../main/windows/manager'; 5 | 6 | import * as dialogManager from './dialog'; 7 | 8 | export class MockWindowManager implements SetOptional< 9 | WindowManager, 10 | 'setEditor' | 'setCropper' | 'setConfig' | 'setDialog' | 'setExports' | 'setPreferences' 11 | > { 12 | editor = { 13 | open: sinon.fake(), 14 | areAnyBlocking: () => false 15 | }; 16 | 17 | dialog = { 18 | open: dialogManager.showDialog, 19 | ...dialogManager 20 | }; 21 | } 22 | 23 | export const windowManager = new MockWindowManager(); 24 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noUnusedLocals": false, 5 | "baseUrl": "." 6 | }, 7 | "include": [ 8 | "**/*.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "main/**/*", 5 | "main/**/*.js", 6 | "test/**/*.ts", 7 | "test/**/*.js" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "dist-js", 5 | "target": "es2019", 6 | "module": "commonjs", 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "sourceMap": true, 10 | "inlineSources": true, 11 | "lib": [ 12 | "esnext" 13 | ] 14 | }, 15 | "include": [ 16 | "node_modules/type-fest/index.d.ts", 17 | "main/**/*" 18 | ], 19 | "exclude": [ 20 | "node_modules" 21 | ] 22 | } 23 | --------------------------------------------------------------------------------