├── .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 |

6 |
7 |
8 | [](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 |
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 |
86 |
93 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------