Full Code of wulkano/Kap for AI

main c42692fa63ac cached
225 files
521.9 KB
138.4k tokens
287 symbols
1 requests
Download .txt
Showing preview only (572K chars total). Download the full file or copy to clipboard to get everything.
Repository: wulkano/Kap
Branch: main
Commit: c42692fa63ac
Files: 225
Total size: 521.9 KB

Directory structure:
gitextract_nqp269lp/

├── .circleci/
│   └── config.yml
├── .editorconfig
├── .gitattributes
├── .github/
│   └── ISSUE_TEMPLATE.md
├── .gitignore
├── .npmrc
├── CODE_OF_CONDUCT.md
├── LICENSE.md
├── PRIVACY.md
├── README.md
├── build/
│   ├── 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
├── package.json
├── renderer/
│   ├── 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/
│   │   │   │   ├── index.tsx
│   │   │   │   ├── left.tsx
│   │   │   │   ├── right.tsx
│   │   │   │   ├── select.tsx
│   │   │   │   └── slider.tsx
│   │   │   ├── options-container.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
│   ├── 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
├── test/
│   ├── convert.ts
│   ├── 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

================================================
FILE CONTENTS
================================================

================================================
FILE: .circleci/config.yml
================================================
version: 2
jobs:
  build:
    macos:
      xcode: '13.4.1'
    steps:
      - checkout
      - run: yarn
      - run: mkdir -p ~/reports
      - run: yarn lint
      - run: yarn test:ci
      - run: yarn run dist
      - run: mv dist/*-x64.dmg dist/Kap-x64.dmg
      - run: mv dist/*-arm64.dmg dist/Kap-arm64.dmg
      - store_artifacts:
          path: dist/Kap-x64.dmg
      - store_artifacts:
          path: dist/Kap-arm64.dmg
      - store_test_results:
          path: ~/reports
  sentry-release:
    docker:
      - image: cimg/node:lts
    environment:
      SENTRY_ORG: wulkano-l0
      SENTRY_PROJECT: kap
    steps:
      - checkout
      - run: |
          curl -sL https://sentry.io/get-cli/ | bash
          export SENTRY_RELEASE=$(yarn run -s sentry-version)
          sentry-cli releases new -p $SENTRY_PROJECT $SENTRY_RELEASE
          sentry-cli releases set-commits --auto $SENTRY_RELEASE
          sentry-cli releases finalize $SENTRY_RELEASE

workflows:
  version: 2
  build:
    jobs:
      - build:
          filters:
            tags:
              only: /.*/ # Force CircleCI to build on tags
      - sentry-release:
          requires:
            - build
          filters:
            tags:
              only: /^v.*/
            branches:
              ignore: /.*/


================================================
FILE: .editorconfig
================================================
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true


================================================
FILE: .gitattributes
================================================
* text=auto eol=lf


================================================
FILE: .github/ISSUE_TEMPLATE.md
================================================
<!--
Thank you for taking the time to report an issue! ❤️

Before you continue; please make sure you've searched our existing issues to avoid duplicates. When you're ready to open a new issue include as much information as possible. You can use the handy template below for bug reports.

macOS version:        The output of `$ sw_vers`. Remember that we currently only support macOS 10.12 or later.
Kap version:          Find this in the about section of Kap, or by right-clicking on the Kap icon and pressing "Get Info".
Step to reproduce:    If applicable, provide steps to reproduce the issue you're having.
Current behavior:     A description of how Kap is currently behaving.
Expected behavior:    How you expected Kap to behave.
Workaround:           A workaround for the issue if you've found on. (this will help others experiencing the same issue!)
-->

**macOS version:**
**Kap version:**

#### Steps to reproduce

#### Current behaviour

#### Expected behaviour

#### Workaround

<!-- If you have additional information, enter it below. -->


================================================
FILE: .gitignore
================================================
node_modules
/renderer/out
/renderer/.next
/app/dist
/dist
/dist-js


================================================
FILE: .npmrc
================================================
package-lock=false


================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct

## Our Pledge

In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.

## Our Standards

Examples of behavior that contributes to creating a positive environment include:

* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members

Examples of unacceptable behavior by participants include:

* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting

## Our Responsibilities

Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.

Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.

## Scope

This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@wulkano.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.

Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]

[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/


================================================
FILE: LICENSE.md
================================================
MIT License

Copyright (c) Wulkano hello@wulkano.com (https://wulkano.com)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: PRIVACY.md
================================================
# Your privacy

Even though Kap is open-source and not in the business of monetizing the product itself nor your data, directly or indirectly, we feel it's important to share how we think about, collect and use insights we gain on how Kap is used through certain [third party services](#third-party-services).

The short version is that we respect your privacy, aim to give you as much choice and control as possible, and try to gather only the minimum amount of data absolutely necessary to help us make Kap better for you and everyone else.

### Security

All our traffic is served over [HTTPS](https://en.wikipedia.org/wiki/HTTPS). Our SSL/TLS certificate is issued by [Let's Encrypt](https://letsencrypt.org). In addition to encryption, we do our best to stay vigilant and implement precautionary measures to avoid breaches or misuse.

### Cookies

We do not allow any [third-party cookies](https://en.wikipedia.org/wiki/HTTP_cookie#Third-party_cookie) and do not attempt to identify you, track you or associate your data across devices and services, nor do we use cookies to serve advertising.

**Authorised cookies:**
- `_g` for Google Analytics
- `_gat_gtag_UA_84705099_3` for Google Analytics
- `_gid` for Google Analytics

You are of course free to block and remove cookies. You will typically find these options in the address bar of your browser.

[Learn more about our use of Google Analytics](#google-analytics-gdpr-compliant).

## Third-party services

An overview of third-party services that have access to, collect or generate data based on your usage.

### Google Analytics (GDPR Compliant)

We do not (and do not allow third-parties to) use our Google Analytics data to track or collect personally identifiable information, nor do we (or do we allow any third-party to) associate data gathered with any personally identifying information from any source.

[Google Analytics data is not shared with other Google products and services](https://support.google.com/analytics/answer/1011397), and is not accessible to Google technical support representatives or Google marketing specialists.

We do not collect or use [user specific metrics](https://support.google.com/analytics/answer/2992042), nor do we [associate data from different devices](https://support.google.com/analytics/answer/3123662).

No [Enhanced Link Attribution](https://support.google.com/analytics/answer/7377126) or [Demographics and Interests reports](https://support.google.com/analytics/answer/2799357) are used or generated, nor do we [track your search queries on our site](https://support.google.com/analytics/answer/1012264). We also do not collect data for [Display and Search Remarketing or Advertising Reporting features](https://support.google.com/analytics/answer/3450482).

Google Analytics data for inactive sessions are retained for the [shortest currently available time](https://support.google.com/analytics/answer/7667196) (14 months). We can however retain analytics data for active sessions longer.

We ask Google Analytics to [anonymize your IP address](https://support.google.com/analytics/answer/2763052).

- [Google Privacy Policy](https://policies.google.com/privacy)
- [How Google Analytics safeguards your data](https://support.google.com/analytics/answer/6004245)
- [Privacy Shield Certificate](https://www.privacyshield.gov/participant?id=a2zt000000001L5AAI)

### Sentry (GDPR Compliant)

We prevent Sentry from storing IP Addresses, in addition to requiring enhanced privacy controls and data scrubbers be applied to prevent [sensitive data](https://docs.sentry.io/learn/sensitive-data/) being stored.

- [Security & Compliance at Sentry](https://sentry.io/security/)
- [GDPR, Sentry, and You](https://blog.sentry.io/2018/03/14/gdpr-sentry-and-you)
- [Privacy Shield Certificate](https://www.privacyshield.gov/participant?id=a2zt0000000TNDzAAO)

### MailChimp (GDPR Compliant)

We are compliant with [MailChimp Terms of Use and anti-spam requirements](https://kb.mailchimp.com/accounts/compliance-tips/terms-of-use-and-anti-spam-requirements) (CAN-SPAM).

When you provide your name and email address to [receive email updates](http://eepurl.com/ch90_1) from us, you acknowledge that the information you provide will be transferred to MailChimp for processing in accordance with their [Privacy Policy](https://mailchimp.com/legal/privacy/) and [Terms](https://mailchimp.com/legal/terms/).

We do not allow MailChimp to use your information in their data science projects.

You can unsubscribe from emails regarding Kap at any time by using the unsubscribe link in the footer of any email you receive from us, or by contacting us at hello@wulkano.com.

- [About MailChimp, the EU/Swiss Privacy Shield, and the GDPR](https://kb.mailchimp.com/accounts/management/about-mailchimp-the-eu-swiss-privacy-shield-and-the-gdpr)
- [Privacy Shield Certificate](https://www.privacyshield.gov/participant?id=a2zt0000000TO6hAAG)

## Help us do even better

We'd love to hear from you! [Open an issue](https://github.com/wulkano/kap/issues/new) or [send an email](mailto:hello@wulkano.com) to help shape and strengthen our stance on privacy.

*Updated May 24, 2018*


================================================
FILE: README.md
================================================
<p align="center">
  <img src="https://getkap.co/static/favicon/kap.svg" height="64">
  <h3 align="center">Kap</h3>
  <p align="center">An open-source screen recorder built with web technology<p>
  <p align="center"><a href="https://circleci.com/gh/wulkano/kap"><img src="https://circleci.com/gh/wulkano/Kap.svg?style=shield" alt="Build Status"></a> <a href="https://github.com/sindresorhus/xo"><img src="https://img.shields.io/badge/code_style-XO-5ed9c7.svg" alt="XO code style"></a></p>
</p>

[![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://vshymanskyy.github.io/StandWithUkraine/)

## Get Kap

Download the latest release:

- [Apple silicon](https://getkap.co/api/download/arm64)
- [Intel](https://getkap.co/api/download/x64)

Or install with [Homebrew-Cask](https://caskroom.github.io):

```sh
brew install --cask kap
```

## How To Use Kap

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.

> Tip: While recording, Option-click the menu bar icon to pause or right-click for more options.

## Contribute

Read the [contribution guide](contributing.md).

## Plugins

For more info on how to create plugins, read the [plugins docs](docs/plugins.md).

## Dev builds

Download [`main`](https://kap-artifacts.now.sh/main) or builds for any other branch using: `https://kap-artifacts.now.sh/<branch>`. Note that these builds are unsupported and may have issues.

## Related Repositories

- [Website](https://github.com/wulkano/kap-website)
- [Aperture](https://github.com/wulkano/aperture)

## Newsletter

[Subscribe](http://eepurl.com/ch90_1)

## Thanks

- [▲ Vercel](https://vercel.com/) for fast deployments served from the edge, hosting our website, downloads, and updates.
- [● CircleCI](https://circleci.com/) for supporting the open source community and making our builds fast and reliable.
- [△ Sentry](https://sentry.io/) for letting us know when Kap isn't behaving and helping us eradicate said behaviour.
- Our [contributors](https://github.com/wulkano/kap/contributors) who help maintain Kap and make screen recording and sharing easy.


================================================
FILE: build/entitlements.mac.inherit.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.cs.allow-jit</key>
    <true/>
    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
    <true/>
    <key>com.apple.security.cs.allow-dyld-environment-variables</key>
    <true/>
    <key>com.apple.security.device.audio-input</key>
    <true/>
    <key>com.apple.security.device.camera</key>
    <true/>
  </dict>
</plist>


================================================
FILE: contributing.md
================================================
# Contributing

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
2. Install the dependencies: `yarn`
3. Build the code, start the app, and watch for changes: `yarn start`

To make sure that your code works in the finished app, you can generate the binary:

```
$ yarn run pack
```

After that, you'll see the binary in the `dist` folder 😀


================================================
FILE: docs/plugins.md
================================================
# Plugins

The Kap plugin system lets you create custom share targets that appear in the editor export menu. You could, for example, create a plugin to share a screen recording on YouTube.

You can discover plugins or view installed ones by clicking the `Kap` menu, `Preferences…`, and selecting the `Plugins` pane.

## Getting started

A Kap plugin is an npm package that exports one or more services. The plugin runs in the main Electron process (Node.js). That means you can use any npm package in your plugin. Kap plugins are published to npm, like any other npm package.

Take a look at existing plugin examples in each section to see how they work.

Tip: You can use modern JavaScript features like async/await in your plugin.

## Requirements

- Your package must be named with the `kap-` prefix. For example `kap-giphy`.
- You must have the `kap-plugin` keyword in package.json. Add additional relevant keywords to improve discovery.
- The `"description"` in package.json should succinctly describe what you can do with it. For example: `Share GIFs on GIPHY`. Not something like this: `Kap plugin that uploads GIFs to GIPHY`.
- The readme should follow the style of [`kap-giphy`](https://github.com/wulkano/kap-giphy).
- Your plugin must be tested, preferably using [`kap-plugin-test`](https://github.com/SamVerschueren/kap-plugin-test) and [`kap-plugin-mock-context`](https://github.com/samverschueren/kap-plugin-mock-context). [Example](https://github.com/wulkano/kap-giphy/blob/main/test/test.js).
- The package.json file can include a `kap` object with the following options:
    - `version`: a [semver range](https://nodesource.com/blog/semver-a-primer/) of the Kap versions your plugin supports. Defaults to `*`.
    - `macosVersion`: a [semver range](https://nodesource.com/blog/semver-a-primer/) of the macOS versions your plugin supports. Defaults to `*`.

- **Deprecation notice:** If your plugin only supports specific versions of Kap, include a `kapVersion` field in the package.json with a [semver range](https://nodesource.com/blog/semver-a-primer/). This is still supported but will be removed at some point in favor of `kap.version`.

## Development

When you develop a plugin it’s useful to be able to try it out in Kap. In the directory of your plugin, run `$ npm link`, go to `~/Library/Application Support/Kap/plugins` and run `$ npm link plugin-name `, and then add `"plugin-name": "latest"` to the `"dependencies"` in the package.json there. Your plugin should now be shown in Kap.

When Kap is built for production, it prunes dependencies at launch time. In order to avoid any issues, make sure to run `$ npm link` after launching Kap, and make sure to re-run it if you restart Kap. Alternatively, you can run Kap in dev mode by downloading the source and running `$ yarn start`.

## Services

Kap currently supports three different types of services and a plugin can have multiple of each, although each plugin should focus on a specific area.

### Share services

A share service lets you add an entry to the export menu in the Kap editor window and control what happens when the user clicks it.

<img src="https://cloud.githubusercontent.com/assets/170270/26560296/8ac42740-44df-11e7-88f5-46f8483ffea1.jpg" width="1024">

```
| Save to Disk      |
| Upload to Dropbox |
| Share on GIPHY    |
```

In the above case, the second and third item are added by two different share services.

The share service is a plain object defining some metadata:

- `title`: The title used in the export menu. For example: `Share to GIPHY`.<br>The text should be in [title case](https://capitalizemytitle.com), for example, `Save to Disk`, not `Save to disk`.
- `configDescription`: A description displayed at the top of the configuration window. You can use this to explain the config options or link to API docs. Any links in this description will be parsed into clickable links automatically.
- `formats`: The file formats you support. Can be: `gif`, `mp4`, `webm`, `apng`
- `action`: The function that is run when the user clicks the menu item. [Read more below.](#action)
- `config`: Definition of the config the plugins needs. [Read more below](#config).

The `config` and `configDescription` properties are optional.

Example:

```js
const action = async context => {
  // Do something

  context.notify('Notify about something');
};

const config = {
  apiKey: {
    title: 'API key',
    type: 'string',
    minLength: 13,
    default: '',
    required: true
  }
};

const giphy = {
  title: 'Share to GIPHY',
  formats: [
    'gif'
  ],
  action,
  config
};

exports.shareServices = [giphy];
```

#### Action

The `action` function is where you implement the behavior of your service. The function receives a `context` argument with some metadata and utility methods.

- `.format`: The file format the user chose in the editor window. Can be: `gif`, `mp4`, `webm`, `apng`
- `.prettyFormat`: Prettified version of `.format` for use in notifications. Can be: `GIF`, `MP4`, `WebM`, `APNG`
- `.defaultFileName`: Default file name for the recording. For example: `Kapture 2017-05-30 at 1.03.49.gif`
- `.filePath()`: Convert the screen recording to the user chosen format and return a Promise for the file path.
  - If you want to overwrite the format that the user selected, you can pass a `fileType` option: `.filePath({fileType: 'mp4'})`. It can be one of `mp4`, `gif`, `apng`, `webm`. This can be useful if you, for example, need to handle the GIF conversion yourself.
- `.config`: Get and set config for your plugin. It’s an instance of [`electron-store`](https://github.com/sindresorhus/electron-store#instance).
- `.request()`: Do a network request, like uploading. It’s a wrapper around [`got`](https://github.com/sindresorhus/got).
- `.copyToClipboard(text)`: Copy text to the clipboard. If you for example copy a link to the uploaded recording to the clipboard, don’t forget to `.notify()` the user about it.
- `.notify(text, action)`: Show a notification. Optionally pass in a function that is called with the event when the notification is clicked.
- `.setProgress(text, percentage)`: Update progress information in the Kap export window. Use this whenever you have long-running jobs, like uploading. The `percentage` should be a number between `0` and `1`.
- `.openConfigFile()`: Open the plugin config file in the user’s editor.
- `.cancel()`: Indicate that the plugin operation canceled for some reason. [Example.](https://github.com/wulkano/kap/blob/efc32d12f381615c9fcfc41065d9c2ee200e8975/app/src/main/save-file-service.js#L28-L31) If the cancelation was not the result of a user gesture, use `.notify()` to inform the user why it was canceled.
- `.waitForDeepLink()`: Returns a Promise that resolves when a deep link for this plugin is opened. The link should be in the format `kap://plugins/{pluginName}/{rest}`, where `pluginName` is the npm package name and `rest` is the string the Promise will resolve with. This is useful for [OAuth flows](#oauth).

#### Notes

Use `context.setProgress()` whenever possible to keep the user updated on what's happening. The `.filePath()` method sets its own progress, so you should not do it for that step.

Example plugins: [`kap-giphy`](https://github.com/wulkano/kap-giphy/blob/main/index.js), [`kap-s3`](https://github.com/SamVerschueren/kap-s3), [`kap-imgur`](https://github.com/kevva/kap-imgur), [`kap-streamable`](https://github.com/kevva/kap-streamable)

### Edit services

Only supported in Kap >= 3.2.0.

An edit service lets you add an entry to the edit menu in the Kap editor window and process the recording before it gets converted and exported. The edit service receives an `mp4` file which is generated from the recording after trimming the duration and adjusting the size. It's expected to produce another `mp4` file at the given output location, which will then be passed to the appropriate share service.

The edit service is a plain object defining some metadata:

- `title`: The title used in the export menu. For example: `Reverse`.\
  The text should be in [title case](https://capitalizemytitle.com), for example, `Slow Down`, not `Slow down`.
- `configDescription`: A description displayed at the top of the configuration window. You can use this to explain the config options or link to API docs. Any links in this description will be parsed into clickable links automatically.
- `action`: The function that is run when the user clicks the menu item. [Read more below.](#action)
- `config`: Definition of the config the plugins needs. [Read more below](#config).

The `config` and `configDescription` properties are optional.

Example:

```js
const action = async context => {
  // Do something

  context.notify('Notify about something');
};

const config = {
  percent: {
    title: 'Slow Down Percentage',
  type: 'number',
  maximum: 1,
  minimum: 0,
    default: 0.5,
    required: true
  }
};

const slowDown = {
  title: 'Slow Down',
  action,
  config
};

exports.editServices = [slowDown];
```

#### Action

The `action` function is where you implement the behavior of your service. The function receives a `context` argument with some metadata and utility methods.

- `.inputPath`: The path to the input trimmed `mp4` file.
- `.outputPath`: The path where the resulting `mp4` file should be by the end of the action.
- `.exportOptions`: An object containing info about the recording (note that the input video has already been resized and trimmed):
  - `.width`: Width of the input file.
  - `.height`: Height of the input file.
  - `.format`: The selected format in which the video will be converted to later on.
  - `.fps`: The selected FPS that will be used for the final conversion.
  - `.duration`: Duration of the trimmed input file.
  - `.isMuted`: Whether the video is muted or not.
  - `.loop`: Whether the resulting GIF or APNG file will be looped or not.
- `.convert(args, text)`: A utility function which accepts an array of `ffmpeg` arguments and handles executing the command, parsing the progress, generating time estimate and showing it to the user. The second argument is optional and defaults to `Converting`. It can be something more descriptive to your service like `Reversing` and will be used for the status reporting.

Example (reversing a video):

```js
const reverseAction = async context => {
  return context.convert([
    '-i',
    context.inputPath,
    '-vf', 'reverse',
    context.outputPath
  ], 'Reversing');

  // Will call ffmpeg -i {inputPath} -vf reverse {outputPath}
};
```
- `.config`: Get and set config for your plugin. It’s an instance of [`electron-store`](https://github.com/sindresorhus/electron-store#instance).
- `.request()`: Do a network request, like uploading. It’s a wrapper around [`got`](https://github.com/sindresorhus/got).
- `.copyToClipboard(text)`: Copy text to the clipboard. If you for example copy a link to the uploaded recording to the clipboard, don’t forget to `.notify()` the user about it.
- `.notify(text, action)`: Show a notification. Optionally pass in a function that is called with the event when the notification is clicked.
- `.openConfigFile()`: Open the plugin config file in the user’s editor.
- `.cancel()`: Indicate that the plugin operation canceled for some reason. [Example.](https://github.com/wulkano/kap/blob/efc32d12f381615c9fcfc41065d9c2ee200e8975/app/src/main/save-file-service.js#L28-L31) If the cancelation was not the result of a user gesture, use `.notify()` to inform the user why it was canceled.
- `.waitForDeepLink()`: Returns a Promise that resolves when a deep link for this plugin is opened. The link should be in the format `kap://plugins/{pluginName}/{rest}`, where `pluginName` is the npm package name and `rest` is the string the Promise will resolve with. This is useful for [OAuth flows](#oauth).

#### Notes

It is highly recomended that an edit service uses a [PCancelable](https://github.com/sindresorhus/p-cancelable) function as the action, so Kap can cancel it in case the user decides to cancel the export.

Example:

```js
const PCancelable = require('p-cancelable');

const action = PCancelable.fn(async (context, onCancel) => {
  const process = context.convert([
    '-i',
    context.inputPath,
    '-vf', 'reverse',
    context.outputPath
  ], 'Reversing');

  onCancel(() => {
    process.cancel();
  });

  await process;
});
```

Example plugins: [`kap-playback-speed`](https://github.com/karaggeorge/kap-playback-speed), [`kap-reverse`](https://github.com/karaggeorge/kap-reverse)

### Record services

A record service lets you add an entry to the “Plugins” submenu of the main context menu of the cropper. A user can enable or disable the service and when enabled, the service can take action in different stages of the recording process.

Record services are different from share and edit services, since they don't have one action but many hooks.

The record service is a plain object defining some metadata and hooks:

- `title`: The title used in the export menu. For example: `Share to GIPHY`.\
  The text should be in [title case](https://capitalizemytitle.com), for example, `Save to Disk`, not `Save to disk`.
- `configDescription`: A description displayed at the top of the configuration window. You can use this to explain the config options or link to API docs. Any links in this description will be parsed into clickable links automatically.
- `config`: Definition of the config the plugins needs. [Read more below](#config).
- `willStartRecording`: Function that is called before the recording starts. [Read more below.](#hooks)
- `didStartRecording`: Function that is called after the recording starts. [Read more below.](#hooks)
- `didStopRecording`: Function that is called after the recording stops. [Read more below.](#hooks)
- `willEnable`: Function that is called when the user enables the service. [Read more below.](#hooks)
- `cleanUp`: Function that is called if Kap exited unexpectedly last time it was run (for example, if it crashed), without the `didStopRecording` hook being called. This hook will only receive the `persistedState` object from the `state` passed to the rest of the hooks. Use this to clean up any effects introduced when the recording started and don't automatically clear out once Kap stops. For example, if your plugin killed a running app with intent to restart it after the recording was over, you can use `cleanUp` to ensure the app is properly restarted even in the event that Kap crashed, so the `didStopRecording` wasn't called.

The `config`, `configDescription` and hook properties are optional.

Example:

```js
const willStartRecording = async context => {
  // Do something
  context.notify('Recording will start now!');
};

const didStopRecording = async context => {
  // Do something
  context.notify('Recording stopped!');
};

const config = {
  apiKey: {
    title: 'API Key',
    type: 'string',
    minLength: 13,
    default: '',
    required: true
  }
};

const doNotDisturb = {
  title: 'Silence Notifications',
  willStartRecording,
  didStopRecording,
  config
};

exports.recordServices = [doNotDisturb];
```

#### Hooks

Each hook is called as described above. Each function can be asynchronous and will be called with a context object described below. The only hook that behaves differently is `willEnable`. This hook will be called when a service is about to be enabled (including after installing the plugin if the config is valid). The hook can be an asynchronous function and if it returns or resolves with `false`, the hook will not be enabled.

You can use this to check if you have enough permissions for the service to work, and if not you can request the missing permissions and return `false`. This ensures that your other plugin hooks will not be called until `willEnable` returns `true`.

#### Hooks Context

The hook functions receive a `context` argument with some metadata and utility methods.

- `.state`: An object that will be shared and passed to all hooks in the same recording process. It can be useful to persist data between the different hooks.
    - `state.persistedState`: An object under `state` which should only contain serializable fields. It will be passed to the `cleanUp` hook if Kap didn't shut down correctly last time it was run. Use this to store fields necessary to clean up remaining effects.
- `.apertureOptions`: An object with the options passed to [Aperture](https://github.com/wulkano/aperture-node). The API is described [here](https://github.com/wulkano/aperture-node#options).
- `.config`: Get and set config for your plugin. It’s an instance of [`electron-store`](https://github.com/sindresorhus/electron-store#instance).
- `.request()`: Do a network request, like uploading. It’s a wrapper around [`got`](https://github.com/sindresorhus/got).
- `.copyToClipboard(text)`: Copy text to the clipboard. If you for example copy a link to the uploaded recording to the clipboard, don’t forget to `.notify()` the user about it.
- `.notify(text, action)`: Show a notification. Optionally pass in a function that is called with the event when the notification is clicked.
- `.openConfigFile()`: Open the plugin config file in the user’s editor.
- `.cancel()`: Indicate that the plugin operation canceled for some reason. [Example.](https://github.com/wulkano/kap/blob/efc32d12f381615c9fcfc41065d9c2ee200e8975/app/src/main/save-file-service.js#L28-L31) If the cancelation was not the result of a user gesture, use `.notify()` to inform the user why it was canceled.
- `.waitForDeepLink()`: Returns a Promise that resolves when a deep link for this plugin is opened. The link should be in the format `kap://plugins/{pluginName}/{rest}`, where `pluginName` is the npm package name and `rest` is the string the Promise will resolve with. This is useful for [OAuth flows](#oauth).

Example plugins: [`kap-do-not-disturb`](https://github.com/karaggeorge/kap-do-not-disturb), [`kap-hide-desktop-icons`](https://github.com/karaggeorge/kap-hide-desktop-icons)

## Config

The config system uses [JSON Schema](http://json-schema.org) which lets you describe the config your plugin supports and have it validated and enforced. For example, you can define that some config key is required, or that it should be a string with a minimum length of 10. Kap will notify the user of invalid config. If you define required config, Kap will open the config file automatically on install so the user can fill out the required fields.

It’s recommended to set an empty `default` property for required config keys, so the user can just fill them out.

The `title` property must be defined for each config key. *(We’ll use it in the future to render your config directly in the UI)*

Example:

```js
config: {
  username: {
    title: 'Username',
    type: 'string',
    minLength: 5,
    default: '',
    required: true
  },
  hasUnicorn: {
    title: 'Do you have a unicorn?',
    type: 'boolean',
    default: false,
    required: true
  }
}
```

[Read more about JSON Schema](https://spacetelescope.github.io/understanding-json-schema/)

### Custom Types

Kap offers a few custom types which can be displayed in a better way to the user.

#### `hexColor`

```js
const config = {
  barColor: {
    title: 'Color',
    customType: 'hexColor',
    required: true,
    default: '#007aff'
  }
};
```

<img src="../media/plugins/hexColor.png" width="319">

#### `keyboardShortcut`

[List of possible values](https://www.electronjs.org/docs/api/accelerator)

```js
const config = {
  keyboardShortcut: {
    title: 'Toggle Unicorn Mode',
    customType: 'keyboardShortcut',
    required: true,
    default: 'Command+Shift+5'
  }
};

// Later

electron.globalShortcut.register(config.get('shortcut'), () => { /* ... */ });
```

**Note:** Kap will not register any action for the keyboard shortcut. That is up to the plugin implementation.

## General APIs

Every type of plugin and service can additionally export the following:
- `didInstall(config)`: A hook that will be called when the plugin is first installed.
- `didConfigChange(newValues, oldValues, config)`: A hook that will be called whenever the config of the plugin is changed.
- `willUninstall(config)`: A hook that will be called when a plugin is being uninstalled. It can be used to clean up artifacts.

In addition to these, each plugin needs to export at least one of the following:
- `shareServices`: an array of share services and described above
- `editServices`: an array of edit services and described above
- `recordServices`: an array of record services and described above

## OAuth

Sometimes services require an [OAuth](https://oauth.net/2/) flow to retrieve a token. These flows are often required to be completed in the browser and not in a webview. For this reason, Kap provides deep linking support. Follow these steps to support OAuth in your plugin:

- When the export starts, check if the `accessToken` is available (if you have already authenticated) in the plugin config. This should not be listed in the JSON Schema options mentioned above unless the user is meant to edit them.
- If it's not available, [open an external link](https://www.electronjs.org/docs/api/shell#shellopenexternalurl-options) to the OAuth provider's page with the correct parameters. Usually client ID.
- When registering the app, provide something like `kap://plugins/{pluginName}/auth` as the callback URL.
- Call `context.waitForDeepLink()` and wait for the user to go through the process.
- When the above call resolves, you'll have the remaining path, along with any extra info the API added. In the above example, it would be something like `auth?code=###`.
- You can now exchange the code for a token, and then store that in the config, so you can use it for future exports.

For an example of this flow in action, check out [kap-dropbox](https://github.com/karaggeorge/kap-dropbox).

If the API provider only allows HTTP/HTTPS URLs, or if you don't want to do the code exchange in the plugin (to avoid having the secret in the code), you might need to create a proxy, similar to the [one used for kap-dropbox](https://github.com/karaggeorge/kap-dropbox/tree/main/oauth-proxy), to trigger the deep link.

## Removing your Kap plugin

Since npm doesn't allow you to remove packages from the registry, Kap filters out deprecated packages in the plugin list.

When you are ready to retire your Kap plugin, simply run `npm deprecate kap-plugin "Deprecated"`.

[Read more about the `npm-deprecate` command](https://docs.npmjs.com/cli/deprecate)


================================================
FILE: main/aperture.ts
================================================
import {windowManager} from './windows/manager';
import {setRecordingTray, setPausedTray, disableTray, resetTray} from './tray';
import {setCropperShortcutAction} from './global-accelerators';
import {settings} from './common/settings';
import {track} from './common/analytics';
import {plugins} from './plugins';
import {getAudioDevices, getSelectedInputDeviceId} from './utils/devices';
import {showError} from './utils/errors';
import {RecordServiceContext, RecordServiceState} from './plugins/service-context';
import {setCurrentRecording, updatePluginState, stopCurrentRecording} from './recording-history';
import {Recording} from './video';
import {ApertureOptions, StartRecordingOptions} from './common/types';
import {InstalledPlugin} from './plugins/plugin';
import {RecordService, RecordServiceHook} from './plugins/service';
import {getCurrentDurationStart, getOverallDuration, setCurrentDurationStart, setOverallDuration} from './utils/track-duration';

const createAperture = require('aperture');
const aperture = createAperture();

let recordingPlugins: Array<{plugin: InstalledPlugin; service: RecordService}> = [];
const serviceState = new Map<string, RecordServiceState>();
let apertureOptions: ApertureOptions;
let recordingName: string | undefined;
let past: number | undefined;

const setRecordingName = (name: string) => {
  recordingName = name;
};

const serializeEditPluginState = () => {
  const result: Record<string, Record<string, Record<string, unknown> | undefined>> = {};

  for (const {plugin, service} of recordingPlugins) {
    if (!result[plugin.name]) {
      result[plugin.name] = {};
    }

    result[plugin.name][service.title] = serviceState.get(service.title)?.persistedState;
  }

  return result;
};

const callPlugins = async (method: RecordServiceHook) => Promise.all(recordingPlugins.map(async ({plugin, service}) => {
  if (service[method] && typeof service[method] === 'function') {
    try {
      await service[method]?.(
        new RecordServiceContext({
          plugin,
          apertureOptions,
          state: serviceState.get(service.title) ?? {},
          setRecordingName
        })
      );
    } catch (error) {
      showError(error as any, {title: `Something went wrong while using the plugin “${plugin.prettyName}”`, plugin});
    }
  }
}));

const cleanup = async () => {
  windowManager.cropper?.close();
  resetTray();

  await callPlugins('didStopRecording');
  serviceState.clear();

  setCropperShortcutAction();
};

export const startRecording = async (options: StartRecordingOptions) => {
  if (past) {
    return;
  }

  past = Date.now();
  recordingName = undefined;

  windowManager.preferences?.close();
  windowManager.cropper?.disable();
  disableTray();

  const {cropperBounds, screenBounds, displayId} = options;

  cropperBounds.y = screenBounds.height - (cropperBounds.y + cropperBounds.height);

  const {
    record60fps,
    showCursor,
    highlightClicks,
    recordAudio
  } = settings.store;

  apertureOptions = {
    fps: record60fps ? 60 : 30,
    cropArea: cropperBounds,
    showCursor,
    highlightClicks,
    screenId: displayId
  };

  if (recordAudio) {
    // In case for some reason the default audio device is not set
    // use the first available device for recording
    const audioInputDeviceId = getSelectedInputDeviceId();
    if (audioInputDeviceId) {
      apertureOptions.audioDeviceId = audioInputDeviceId;
    } else {
      const [defaultAudioDevice] = await getAudioDevices();
      apertureOptions.audioDeviceId = defaultAudioDevice?.id;
    }
  }

  // TODO: figure out how to correctly process hevc videos with ffmpeg
  // if (recordHevc) {
  //   apertureOptions.videoCodec = 'hevc';
  // }

  console.log(`Collected settings after ${(Date.now() - past) / 1000}s`);

  recordingPlugins = plugins
    .recordingPlugins
    .flatMap(
      plugin => {
        const validServices = plugin.config.validServices;
        return plugin.recordServicesWithStatus
          // Make sure service is valid and enabled
          .filter(({title, isEnabled}) => isEnabled && validServices.includes(title))
          .map(service => ({plugin, service}));
      }
    );

  for (const {service, plugin} of recordingPlugins) {
    serviceState.set(service.title, {persistedState: {}});
    track(`plugins/used/record/${plugin.name}`);
  }

  await callPlugins('willStartRecording');

  try {
    const filePath = await aperture.startRecording(apertureOptions);
    setOverallDuration(0);
    setCurrentDurationStart(Date.now());

    setCurrentRecording({
      filePath,
      name: recordingName,
      apertureOptions,
      plugins: serializeEditPluginState()
    });
  } catch (error) {
    track('recording/stopped/error');
    showError(error as any, {title: 'Recording error', plugin: undefined});
    past = undefined;
    cleanup();
    return;
  }

  const startTime = (Date.now() - past) / 1000;
  if (startTime > 3) {
    track(`recording/started/${startTime}`);
  } else {
    track('recording/started');
  }

  console.log(`Started recording after ${startTime}s`);
  windowManager.cropper?.setRecording();
  setRecordingTray();
  setCropperShortcutAction(stopRecording);
  past = Date.now();

  // Track aperture errors after recording has started, to avoid kap freezing if something goes wrong
  aperture.recorder.catch((error: any) => {
    // Make sure it doesn't catch the error of ending the recording
    if (past) {
      track('recording/stopped/error');
      showError(error, {title: 'Recording error', plugin: undefined});
      past = undefined;
      cleanup();
    }
  });

  await callPlugins('didStartRecording');
  updatePluginState(serializeEditPluginState());
};

export const stopRecording = async () => {
  // Ensure we only stop recording once
  if (!past) {
    return;
  }

  console.log(`Stopped recording after ${(Date.now() - past) / 1000}s`);
  past = undefined;

  let filePath;

  try {
    filePath = await aperture.stopRecording();
    setOverallDuration(0);
    setCurrentDurationStart(0);
  } catch (error) {
    track('recording/stopped/error');
    showError(error as any, {title: 'Recording error', plugin: undefined});
    cleanup();
    return;
  }

  try {
    cleanup();
  } finally {
    track('editor/opened/recording');

    const recording = new Recording({
      filePath,
      title: recordingName,
      apertureOptions
    });
    await recording.openEditorWindow();

    stopCurrentRecording(recordingName);
  }
};

export const stopRecordingWithNoEdit = async () => {
  // Ensure we only stop recording once
  if (!past) {
    return;
  }

  console.log(`Stopped recording after ${(Date.now() - past) / 1000}s`);
  past = undefined;

  try {
    await aperture.stopRecording();
    setOverallDuration(0);
    setCurrentDurationStart(0);
  } catch (error) {
    track('recording/quit/error');
    showError(error as any, {title: 'Recording error', plugin: undefined});
    cleanup();
    return;
  }

  try {
    cleanup();
  } finally {
    track('recording/quit');
    stopCurrentRecording(recordingName);
  }
};

export const pauseRecording = async () => {
  // Ensure we only pause if there's a recording in progress and if it's currently not paused
  const isPaused = await aperture.isPaused();
  if (!past || isPaused) {
    return;
  }

  try {
    await aperture.pause();
    setOverallDuration(getOverallDuration() + (Date.now() - getCurrentDurationStart()));
    setCurrentDurationStart(0);
    setPausedTray();
    track('recording/paused');
    console.log(`Paused recording after ${(Date.now() - past) / 1000}s`);
  } catch (error) {
    track('recording/paused/error');
    showError(error as any, {title: 'Recording error', plugin: undefined});
    cleanup();
  }
};

export const resumeRecording = async () => {
  // Ensure we only resume if there's a recording in progress and if it's currently paused
  const isPaused = await aperture.isPaused();
  if (!past || !isPaused) {
    return;
  }

  try {
    await aperture.resume();
    setCurrentDurationStart(Date.now());
    setRecordingTray();
    track('recording/resumed');
    console.log(`Resume recording after ${(Date.now() - past) / 1000}s`);
  } catch (error) {
    track('recording/resumed/error');
    showError(error as any, {title: 'Recording error', plugin: undefined});
    cleanup();
  }
};


================================================
FILE: main/common/accelerator-validator.ts
================================================
// The goal of this file is validating accelerator values we receive from the user
// to make sure that they are can be used with the electron api https://www.electronjs.org/docs/api/accelerator

// Also, this extracts the right accelerator from a keyboard event, checking the
// location for numpad keys and special characters for when shift is pressed

const modifiers = ['Command', 'Alt', 'Option', 'Shift', 'Cmd', 'Control', 'Ctrl'];

const codes = [
  'Plus',
  'Space',
  'Tab',
  'Capslock',
  'Numlock',
  'Scrolllock',
  'Backspace',
  'Delete',
  'Insert',
  'Return',
  'Enter',
  'Up',
  'Down',
  'Left',
  'Right',
  'PageUp',
  'PageDown',
  'Escape',
  'Esc',
  'VolumeUp',
  'VolumeDown',
  'VolumeMute',
  'num0',
  'num1',
  'num2',
  'num3',
  'num4',
  'num5',
  'num6',
  'num7',
  'num8',
  'num9',
  'numdec',
  'numadd',
  'numsub',
  'nummult',
  'numdiv'
] as const;

const getKeyCodeRegex = () => new RegExp('^([\\dA-Z~`!@#$%^&*()_+=.,<>?;:\'"\\-\\/\\\\\\[\\]\\{\\}\\|]|F([1-9]|1[\\d]|2[0-4])|' + codes.join('|') + ')$');

const shiftKeyMap = new Map([
  ['~', '`'],
  ['!', '1'],
  ['@', '2'],
  ['#', '3'],
  ['$', '4'],
  ['%', '5'],
  ['^', '6'],
  ['&', '7'],
  ['*', '8'],
  ['(', '9'],
  [')', '0'],
  ['_', '-'],
  ['+', '='],
  ['{', '['],
  ['}', ']'],
  ['|', '\\'],
  [':', ';'],
  ['"', '\''],
  ['<', ','],
  ['>', '.'],
  ['?', '/']
]);

const numpadKeyMap = new Map([
  ['0', 'num0'],
  ['1', 'num1'],
  ['2', 'num2'],
  ['3', 'num3'],
  ['4', 'num4'],
  ['5', 'num5'],
  ['6', 'num6'],
  ['7', 'num7'],
  ['8', 'num8'],
  ['9', 'num9'],
  ['.', 'numdec'],
  ['+', 'numadd'],
  ['-', 'numsub'],
  ['*', 'nummult'],
  ['/', 'numdiv']
]);

const namedKeyCodeMap = new Map([
  [' ', 'Space'],
  ['CapsLock', 'Capslock'],
  ['ArrowUp', 'Up'],
  ['ArrowDown', 'Down'],
  ['ArrowLeft', 'Left'],
  ['ArrowRight', 'Right'],
  ['Clear', 'Numlock']
]);

export const checkAccelerator = (accelerator: string) => {
  if (!accelerator) {
    return true;
  }

  const parts = accelerator.split('+');

  if (parts.length < 2) {
    return false;
  }

  if (!getKeyCodeRegex().test(parts[parts.length - 1])) {
    return false;
  }

  const metaKeys = parts.slice(0, -1);
  return metaKeys.every(part => modifiers.includes(part)) && metaKeys.some(part => part !== 'Shift');
};

export const eventKeyToAccelerator = (key: string, location: number) => {
  if (location === 3) {
    return numpadKeyMap.get(key);
  }

  return namedKeyCodeMap.get(key) ?? shiftKeyMap.get(key) ?? key.toUpperCase();
};


================================================
FILE: main/common/analytics.ts
================================================
'use strict';

import util from 'electron-util';
import {parse} from 'semver';
import {settings} from './settings';

// TODO: Disabled because of https://github.com/wulkano/Kap/issues/1126
/// const Insight = require('insight');
const pkg = require('../../package');

/// const trackingCode = 'UA-84705099-2';
/// const insight = new Insight({trackingCode, pkg});

const version = parse(pkg.version);

export const track = (...paths: string[]) => {
  const allowAnalytics = settings.get('allowAnalytics');

  if (allowAnalytics) {
    console.log('Tracking', `v${version?.major}.${version?.minor}`, ...paths);
    /// insight.track(`v${version?.major}.${version?.minor}`, ...paths);
  }
};

export const initializeAnalytics = () => {
  if (util.isFirstAppLaunch()) {
    /// insight.track('install');
  }

  if (settings.get('version') !== pkg.version) {
    track('install');
    settings.set('version', pkg.version);
  }
};


================================================
FILE: main/common/constants.ts
================================================
import {Format} from './types';

export const supportedVideoExtensions = ['mp4', 'mov', 'm4v'];

const formatExtensions = new Map([
  ['av1', 'mp4'],
  ['hevc', 'mp4']
]);

export const formats = [Format.mp4, Format.hevc, Format.av1, Format.gif, Format.apng, Format.webm];

export const getFormatExtension = (format: Format) => formatExtensions.get(format) ?? format;

export const defaultInputDeviceId = 'SYSTEM_DEFAULT';


================================================
FILE: main/common/flags.ts
================================================
import Store from 'electron-store';

export const flags = new Store<{
  backgroundEditorConversion: boolean;
  editorDragTooltip: boolean;
}>({
  name: 'flags',
  defaults: {
    backgroundEditorConversion: false,
    editorDragTooltip: false
  }
});


================================================
FILE: main/common/settings.ts
================================================
'use strict';

import {homedir} from 'os';
import Store from 'electron-store';

const {defaultInputDeviceId} = require('./constants');
const shortcutToAccelerator = require('../utils/shortcut-to-accelerator');

export const shortcuts = {
  triggerCropper: 'Toggle Kap'
};

const shortcutSchema = {
  type: 'string',
  default: ''
};

interface Settings {
  kapturesDir: string;
  allowAnalytics: boolean;
  showCursor: boolean;
  highlightClicks: boolean;
  record60fps: boolean;
  loopExports: boolean;
  recordKeyboardShortcut: boolean;
  recordAudio: boolean;
  audioInputDeviceId?: string;
  cropperShortcut: {
    metaKey: boolean;
    altKey: boolean;
    ctrlKey: boolean;
    shiftKey: boolean;
    character: string;
  };
  lossyCompression: boolean;
  enableShortcuts: boolean;
  shortcuts: {
    [key in keyof typeof shortcuts]: string
  };
  version: string;
}

export const settings = new Store<Settings>({
  schema: {
    kapturesDir: {
      type: 'string',
      default: `${homedir()}/Movies/Kaptures`
    },
    allowAnalytics: {
      type: 'boolean',
      default: true
    },
    showCursor: {
      type: 'boolean',
      default: true
    },
    highlightClicks: {
      type: 'boolean',
      default: false
    },
    record60fps: {
      type: 'boolean',
      default: false
    },
    loopExports: {
      type: 'boolean',
      default: true
    },
    recordKeyboardShortcut: {
      type: 'boolean',
      default: true
    },
    recordAudio: {
      type: 'boolean',
      default: false
    },
    audioInputDeviceId: {
      type: [
        'string',
        'null'
      ],
      default: defaultInputDeviceId
    },
    cropperShortcut: {
      type: 'object',
      properties: {
        metaKey: {
          type: 'boolean',
          default: true
        },
        altKey: {
          type: 'boolean',
          default: false
        },
        ctrlKey: {
          type: 'boolean',
          default: false
        },
        shiftKey: {
          type: 'boolean',
          default: true
        },
        character: {
          type: 'string',
          default: '5'
        }
      }
    },
    lossyCompression: {
      type: 'boolean',
      default: false
    },
    enableShortcuts: {
      type: 'boolean',
      default: true
    },
    shortcuts: {
      type: 'object',
      // eslint-disable-next-line unicorn/no-array-reduce
      properties: Object.keys(shortcuts).reduce((acc, key) => ({...acc, [key]: shortcutSchema}), {}),
      default: {}
    },
    version: {
      type: 'string',
      default: ''
    }
  }
});

// TODO: Remove this when we feel like everyone has migrated
if (settings.has('recordKeyboardShortcut')) {
  settings.set('enableShortcuts', settings.get('recordKeyboardShortcut'));
  settings.delete('recordKeyboardShortcut');
}

// TODO: Remove this when we feel like everyone has migrated
if (settings.has('cropperShortcut')) {
  settings.set('shortcuts.triggerCropper', shortcutToAccelerator(settings.get('cropperShortcut')));
  settings.delete('cropperShortcut');
}

settings.set('cropper' as any, {});
settings.set('actionBar' as any, {});


================================================
FILE: main/common/system-permissions.ts
================================================
import {systemPreferences, shell, dialog, app} from 'electron';
const {hasScreenCapturePermission, hasPromptedForPermission} = require('mac-screen-capture-permissions');
const {ensureDockIsShowing} = require('../utils/dock');

let isDialogShowing = false;

const promptSystemPreferences = (options: {message: string; detail: string; systemPreferencesPath: string}) => async ({hasAsked}: {hasAsked?: boolean} = {}) => {
  if (hasAsked || isDialogShowing) {
    return false;
  }

  isDialogShowing = true;
  await ensureDockIsShowing(async () => {
    const {response} = await dialog.showMessageBox({
      type: 'warning',
      buttons: ['Open System Preferences', 'Cancel'],
      defaultId: 0,
      message: options.message,
      detail: options.detail,
      cancelId: 1
    });
    isDialogShowing = false;

    if (response === 0) {
      await openSystemPreferences(options.systemPreferencesPath);
      app.quit();
    }
  });

  return false;
};

export const openSystemPreferences = async (path: string) => shell.openExternal(`x-apple.systempreferences:com.apple.preference.security?${path}`);

// Microphone

const getMicrophoneAccess = () => systemPreferences.getMediaAccessStatus('microphone');

const microphoneFallback = promptSystemPreferences({
  message: 'Kap cannot access the microphone.',
  detail: 'Kap requires microphone access to be able to record audio. You can grant this in the System Preferences. Afterwards, launch Kap for the changes to take effect.',
  systemPreferencesPath: 'Privacy_Microphone'
});

export const ensureMicrophonePermissions = async (fallback = microphoneFallback) => {
  const access = getMicrophoneAccess();

  if (access === 'granted') {
    return true;
  }

  if (access !== 'denied') {
    const granted = await systemPreferences.askForMediaAccess('microphone');

    if (granted) {
      return true;
    }

    return fallback({hasAsked: true});
  }

  return fallback();
};

export const hasMicrophoneAccess = () => getMicrophoneAccess() === 'granted';

// Screen Capture (10.15 and newer)

const screenCaptureFallback = promptSystemPreferences({
  message: 'Kap cannot record the screen.',
  detail: 'Kap requires screen capture access to be able to record the screen. You can grant this in the System Preferences. Afterwards, launch Kap for the changes to take effect.',
  systemPreferencesPath: 'Privacy_ScreenCapture'
});

export const ensureScreenCapturePermissions = (fallback = screenCaptureFallback) => {
  const hadAsked = hasPromptedForPermission();

  const hasAccess = hasScreenCapturePermission();

  if (hasAccess) {
    return true;
  }

  fallback({hasAsked: !hadAsked});
  return false;
};

export const hasScreenCaptureAccess = () => hasScreenCapturePermission();



================================================
FILE: main/common/types/base.ts
================================================
import {Rectangle} from 'electron';

export enum Format {
  gif = 'gif',
  hevc = 'hevc',
  mp4 = 'mp4',
  webm = 'webm',
  apng = 'apng',
  av1 = 'av1'
}

export enum Encoding {
  h264 = 'h264',
  hevc = 'hevc',
  // eslint-disable-next-line unicorn/prevent-abbreviations
  proRes422 = 'proRes422',
  // eslint-disable-next-line unicorn/prevent-abbreviations
  proRes4444 = 'proRes4444'
}

export type App = {
  url: string;
  isDefault: boolean;
  icon: string;
  name: string;
};

export interface ApertureOptions {
  fps: number;
  cropArea: Rectangle;
  showCursor: boolean;
  highlightClicks: boolean;
  screenId: number;
  audioDeviceId?: string;
  videoCodec?: Encoding;
}

export interface StartRecordingOptions {
  cropperBounds: Rectangle;
  screenBounds: Rectangle;
  displayId: number;
}


================================================
FILE: main/common/types/conversion-options.ts
================================================
import {App, Format} from './base';

export type CreateExportOptions = {
  filePath: string;
  conversionOptions: ConversionOptions;
  format: Format;
  plugins: {
    share: {
      pluginName: string;
      serviceTitle: string;
      app?: App;
    };
  };
};

export type EditServiceInfo = {
  pluginName: string;
  serviceTitle: string;
};

export type ConversionOptions = {
  startTime: number;
  endTime: number;
  width: number;
  height: number;
  fps: number;
  shouldCrop: boolean;
  shouldMute: boolean;
  editService?: EditServiceInfo;
};

export enum ExportStatus {
  inProgress = 'inProgress',
  failed = 'failed',
  canceled = 'canceled',
  completed = 'completed'
}


================================================
FILE: main/common/types/index.ts
================================================
export * from './base';
export * from './remote-states';
export * from './conversion-options';
export * from './window-states';


================================================
FILE: main/common/types/remote-states.ts
================================================
import {App, Format} from './base';
import {ExportStatus} from './conversion-options';

// eslint-disable-next-line @typescript-eslint/ban-types
export type RemoteState<State = any, Actions extends Record<string, (...args: any[]) => any> = {}> = {
  actions: Actions;
  state: State;
};

export type RemoteStateHook<Base extends RemoteState> = Base extends RemoteState<infer State, infer Actions> ? (
  Actions & {
    state: State;
    isLoading: boolean;
    refreshState: () => void;
  }
) : never;

export type RemoteStateHandler<Base extends RemoteState> = Base extends RemoteState<infer State, infer Actions> ? (sendUpdate: (state: State, id?: string) => void) => {
  actions: {
    [Key in keyof Actions]: Actions[Key] extends (...args: any[]) => any ? (id: string, ...args: Parameters<Actions[Key]>) => void : never
  };
  getState: (id: string) => State | undefined;
} : never;

export interface ExportOptionsPlugin {
  title: string;
  pluginName: string;
  pluginPath: string;
  apps?: App[];
  lastUsed: number;
}

export type ExportOptionsFormat = {
  plugins: ExportOptionsPlugin[];
  format: Format;
  prettyFormat: string;
  lastUsed: number;
};

export type ExportOptionsEditService = {
  title: string;
  pluginName: string;
  pluginPath: string;
  hasConfig: boolean;
};

export type ExportOptions = {
  formats: ExportOptionsFormat[];
  editServices: ExportOptionsEditService[];
  fpsHistory: {[key in Format]: number};
};

export type EditorOptionsRemoteState = RemoteState<ExportOptions, {
  updatePluginUsage: ({format, plugin}: {
    format: Format;
    plugin: string;
  }) => void;
  updateFpsUsage: ({format, fps}: {
    format: Format;
    fps: number;
  }) => void;
}>;

export interface ExportState {
  id: string;
  title: string;
  description: string;
  message: string;
  progress?: number;
  image?: string;
  filePath?: string;
  error?: Error;
  fileSize?: string;
  status: ExportStatus;
  canCopy: boolean;
  disableOutputActions: boolean;
  canPreviewExport: boolean;
  titleWithFormat: string;
}

export type ExportsRemoteState = RemoteState<ExportState, {
  copy: () => void;
  cancel: () => void;
  retry: () => void;
  openInEditor: () => void;
  showInFolder: () => void;
}>;

export type ExportsListRemoteState = RemoteState<string[]>;


================================================
FILE: main/common/types/window-states.ts
================================================

export interface EditorWindowState {
  fps: number;
  previewFilePath: string;
  filePath: string;
  title: string;
  conversionId?: string;
}


================================================
FILE: main/conversion.ts
================================================
import fs from 'fs';
import {app, clipboard} from 'electron';
import {EventEmitter} from 'events';
import {ConversionOptions, Format} from './common/types';
import {Video} from './video';
import {convertTo} from './converters';
import hash from 'object-hash';
import {notify} from './utils/notifications';
import PCancelable from 'p-cancelable';
import prettyBytes from 'pretty-bytes';
import TypedEventEmitter from 'typed-emitter';

const plist = require('plist');

// A conversion object describes the process of converting a video or recording
// using ffmpeg that can then be shared multiple times using Share plugins
export default class Conversion extends (EventEmitter as new () => TypedEventEmitter<ConversionEvents>) {
  static conversionMap = new Map<string, Conversion>();

  static get all() {
    return [...this.conversionMap.values()];
  }

  readonly id: string;
  finalSize?: string;
  convertedFilePath?: string;

  get canCopy() {
    return Boolean(this.convertedFilePath && [Format.gif, Format.apng, Format.mp4].includes(this.format));
  }

  private conversionProcess?: PCancelable<string>;

  constructor(
    public readonly video: Video,
    public readonly format: Format,
    public readonly options: ConversionOptions
  ) {
    // eslint-disable-next-line constructor-super
    super();

    this.id = hash({
      filePath: video.filePath,
      format,
      options
    });

    Conversion.conversionMap.set(this.id, this);
  }

  static fromId(id: string) {
    return this.conversionMap.get(id);
  }

  static getOrCreate(video: Video, format: Format, options: ConversionOptions) {
    const id = hash({
      filePath: video.filePath,
      format,
      options
    });

    return this.fromId(id) ?? new Conversion(video, format, options);
  }

  copy = () => {
    clipboard.writeBuffer('NSFilenamesPboardType', Buffer.from(plist.build([this.convertedFilePath])));
    notify({
      body: 'The file has been copied to the clipboard',
      title: app.name
    });
  };

  async filePathExists() {
    if (!this.convertedFilePath) {
      return false;
    }

    try {
      await fs.promises.access(this.convertedFilePath, fs.constants.F_OK);
      return true;
    } catch {
      return false;
    }
  }

  filePath = async () => {
    if (!this.conversionProcess) {
      this.start();
    }

    try {
      this.convertedFilePath = await this.conversionProcess;
      this.emit('completed');
      this.calculateFileSize(this.convertedFilePath);
      return this.convertedFilePath!;
    } catch (error) {
      // Ensure we re-try the conversion if it fails
      this.conversionProcess = undefined;
      if (!(error as any)?.isCanceled) {
        this.emit('error', error as any);
      }

      throw error;
    }
  };

  cancel = () => {
    if (!this.conversionProcess?.isCanceled && !this.convertedFilePath) {
      this.conversionProcess?.cancel();
    }
  };

  private readonly onConversionProgress = (action: string, progress: number, estimate?: string) => {
    const text = estimate ? `${action} — ${estimate} remaining` : `${action}…`;
    this.emit('progress', text, Math.max(Math.min(progress, 1), 0));
  };

  private readonly calculateFileSize = async (filePath?: string) => {
    if (!filePath) {
      return;
    }

    try {
      const {size} = await fs.promises.stat(filePath);
      this.finalSize = prettyBytes(size);
      this.emit('file-size', this.finalSize);
    } catch {}
  };

  private readonly start = () => {
    this.conversionProcess = convertTo(
      this.format,
      {
        ...this.options,
        defaultFileName: this.video.title,
        inputPath: this.video.filePath,
        onProgress: this.onConversionProgress,
        onCancel: () => {
          this.emit('cancel');
        }
      },
      this.video.encoding
    );
  };
}

interface ConversionEvents {
  progress: (text: string, percentage: number) => void;
  error: (error: Error) => void;
  cancel: () => void;
  completed: () => void;
  'file-size': (size: string) => void;
}


================================================
FILE: main/converters/h264.ts
================================================
import PCancelable from 'p-cancelable';
import tempy from 'tempy';
import {compress, convert} from './process';
import {areDimensionsEven, conditionalArgs, ConvertOptions, makeEven} from './utils';
import {settings} from '../common/settings';
import os from 'os';
import {Format} from '../common/types';
import fs from 'fs';

// `time ffmpeg -i original.mp4 -vf fps=30,scale=480:-1::flags=lanczos,palettegen palette.png`
// `time ffmpeg -i original.mp4 -i palette.png -filter_complex 'fps=30,scale=-1:-1:flags=lanczos[x]; [x][1:v]paletteuse' palette.gif`
const convertToGif = PCancelable.fn(async (options: ConvertOptions, onCancel: PCancelable.OnCancelFunction) => {
  const palettePath = tempy.file({extension: 'png'});

  const paletteProcess = convert(palettePath, {shouldTrack: false}, conditionalArgs(
    '-i', options.inputPath,
    '-vf', `fps=${options.fps}${options.shouldCrop ? `,scale=${options.width}:${options.height}:flags=lanczos` : ''},palettegen`,
    {
      args: [
        '-ss',
        options.startTime.toString(),
        '-to',
        options.endTime.toString()
      ],
      if: options.shouldCrop
    },
    palettePath
  ));

  onCancel(() => {
    paletteProcess.cancel();
  });

  await paletteProcess;

  // Sometimes if the clip is too short or fps too low, the palette is not generated
  const hasPalette = fs.existsSync(palettePath);

  const shouldLoop = settings.get('loopExports');

  const conversionProcess = convert(options.outputPath, {
    onProgress: (progress, estimate) => {
      options.onProgress('Converting', progress, estimate);
    },
    startTime: options.startTime,
    endTime: options.endTime
  }, conditionalArgs(
    '-i', options.inputPath,
    {
      args: [
        '-i',
        palettePath,
        '-filter_complex',
        `fps=${options.fps}${options.shouldCrop ? `,scale=${options.width}:${options.height}:flags=lanczos` : ''}[x]; [x][1:v]paletteuse`
      ],
      if: hasPalette
    },
    {
      args: [
        '-vf',
        `fps=${options.fps}${options.shouldCrop ? `,scale=${options.width}:${options.height}:flags=lanczos` : ''}`
      ],
      if: !hasPalette
    },
    '-loop', shouldLoop ? '0' : '-1', // 0 == forever; -1 == no loop
    {
      args: [
        '-ss',
        options.startTime.toString(),
        '-to',
        options.endTime.toString()
      ],
      if: options.shouldCrop
    },
    options.outputPath
  ));

  onCancel(() => {
    conversionProcess.cancel();
  });

  await conversionProcess;

  const compressProcess = compress(options.outputPath, {
    onProgress: (progress, estimate) => {
      options.onProgress('Compressing', progress, estimate);
    },
    startTime: options.startTime,
    endTime: options.endTime
  }, [
    '--batch',
    options.outputPath
  ]);

  onCancel(() => {
    compressProcess.cancel();
  });

  await compressProcess;

  return options.outputPath;
});

// eslint-disable-next-line @typescript-eslint/promise-function-async
const convertToMp4 = (options: ConvertOptions) => convert(options.outputPath, {
  onProgress: (progress, estimate) => {
    options.onProgress('Converting', progress, estimate);
  },
  startTime: options.startTime,
  endTime: options.endTime
}, conditionalArgs(
  '-i', options.inputPath,
  '-r', options.fps.toString(),
  {
    args: ['-an'],
    if: options.shouldMute
  },
  {
    args: [
      '-s',
      `${makeEven(options.width)}x${makeEven(options.height)}`,
      '-ss',
      options.startTime.toString(),
      '-to',
      options.endTime.toString()
    ],
    if: options.shouldCrop || !areDimensionsEven(options)
  },
  options.outputPath
));

// eslint-disable-next-line @typescript-eslint/promise-function-async
const convertToWebm = (options: ConvertOptions) => convert(options.outputPath, {
  onProgress: (progress, estimate) => {
    options.onProgress('Converting', progress, estimate);
  },
  startTime: options.startTime,
  endTime: options.endTime
}, conditionalArgs(
  '-i', options.inputPath,
  // http://wiki.webmproject.org/ffmpeg
  // https://trac.ffmpeg.org/wiki/Encode/VP9
  '-threads', Math.max(os.cpus().length - 1, 1).toString(),
  '-deadline', 'good', // `best` is twice as slow and only slighty better
  '-b:v', '1M', // Bitrate (same as the MP4)
  '-codec:v', 'vp9',
  '-codec:a', 'vorbis',
  '-ac', '2', // https://stackoverflow.com/questions/19004762/ffmpeg-covert-from-mp4-to-webm-only-working-on-some-files
  '-strict', '-2', // Needed because `vorbis` is experimental
  '-r', options.fps.toString(),
  {
    args: ['-an'],
    if: options.shouldMute
  },
  {
    args: [
      '-s',
      `${makeEven(options.width)}x${makeEven(options.height)}`,
      '-ss',
      options.startTime.toString(),
      '-to',
      options.endTime.toString()
    ],
    if: options.shouldCrop || !areDimensionsEven(options)
  },
  options.outputPath
));

// eslint-disable-next-line @typescript-eslint/promise-function-async
const convertToAv1 = (options: ConvertOptions) => convert(options.outputPath, {
  onProgress: (progress, estimate) => {
    options.onProgress('Converting', progress, estimate);
  },
  startTime: options.startTime,
  endTime: options.endTime
}, conditionalArgs(
  '-i', options.inputPath,
  '-r', options.fps.toString(),
  '-c:v', 'libaom-av1',
  '-c:a', 'libopus',
  '-crf', '34',
  '-b:v', '0',
  '-strict', 'experimental',
  // Enables row-based multi-threading which maximizes CPU usage
  // https://trac.ffmpeg.org/wiki/Encode/AV1
  '-cpu-used', '4',
  '-row-mt', '1',
  '-tiles', '2x2',
  {
    args: ['-an'],
    if: options.shouldMute
  },
  {
    args: [
      '-s',
      `${makeEven(options.width)}x${makeEven(options.height)}`,
      '-ss',
      options.startTime.toString(),
      '-to',
      options.endTime.toString()
    ],
    if: options.shouldCrop || !areDimensionsEven(options)
  },
  options.outputPath
));

// eslint-disable-next-line @typescript-eslint/promise-function-async
const convertToHevc = (options: ConvertOptions) => convert(options.outputPath, {
  onProgress: (progress, estimate) => {
    options.onProgress('Converting', progress, estimate);
  },
  startTime: options.startTime,
  endTime: options.endTime
}, conditionalArgs(
  '-i', options.inputPath,
  '-r', options.fps.toString(),
  '-c:v', 'libx265',
  '-c:a', 'libopus',
  '-preset', 'medium',
  '-tag:v', 'hvc1', // Metadata for macOS
  {
    args: ['-an'],
    if: options.shouldMute
  },
  {
    args: [
      '-s',
      `${makeEven(options.width)}x${makeEven(options.height)}`,
      '-ss',
      options.startTime.toString(),
      '-to',
      options.endTime.toString()
    ],
    if: options.shouldCrop || !areDimensionsEven(options)
  },
  options.outputPath
));

// eslint-disable-next-line @typescript-eslint/promise-function-async
const convertToApng = (options: ConvertOptions) => convert(options.outputPath, {
  onProgress: (progress, estimate) => {
    options.onProgress('Converting', progress, estimate);
  },
  startTime: options.startTime,
  endTime: options.endTime
}, conditionalArgs(
  '-i', options.inputPath,
  '-vf', `fps=${options.fps}${options.shouldCrop ? `,scale=${options.width}:${options.height}:flags=lanczos` : ''}`,
  // Strange for APNG instead of -loop it uses -plays see: https://stackoverflow.com/questions/43795518/using-ffmpeg-to-create-looping-apng
  '-plays', settings.get('loopExports') ? '0' : '1', // 0 == forever; 1 == no loop
  {
    args: ['-an'],
    if: options.shouldMute
  },
  {
    args: [
      '-ss',
      options.startTime.toString(),
      '-to',
      options.endTime.toString()
    ],
    if: options.shouldCrop
  },
  options.outputPath
));

// eslint-disable-next-line @typescript-eslint/promise-function-async
export const crop = (options: ConvertOptions) => convert(options.outputPath, {
  onProgress: (progress, estimate) => {
    options.onProgress('Cropping', progress, estimate);
  },
  startTime: options.startTime,
  endTime: options.endTime
}, conditionalArgs(
  '-i', options.inputPath,
  '-s', `${makeEven(options.width)}x${makeEven(options.height)}`,
  '-ss', options.startTime.toString(),
  '-to', options.endTime.toString(),
  options.outputPath
));

export default new Map([
  [Format.gif, convertToGif],
  [Format.mp4, convertToMp4],
  [Format.hevc, convertToHevc],
  [Format.webm, convertToWebm],
  [Format.apng, convertToApng],
  [Format.av1, convertToAv1]
]);


================================================
FILE: main/converters/index.ts
================================================
import path from 'path';
import tempy from 'tempy';
import {Encoding, Format} from '../common/types';
import {track} from '../common/analytics';
import h264Converters, {crop as h264Crop} from './h264';
import {ConvertOptions} from './utils';
import {getFormatExtension} from '../common/constants';
import PCancelable, {OnCancelFunction} from 'p-cancelable';
import {convert} from './process';
import {plugins} from '../plugins';
import {EditServiceContext} from '../plugins/service-context';
import {settings} from '../common/settings';
import {Except} from 'type-fest';

const converters = new Map([
  [Encoding.h264, h264Converters]
]);

const croppingHandlers = new Map([
  [Encoding.h264, h264Crop]
]);

// eslint-disable-next-line @typescript-eslint/promise-function-async
export const convertTo = (
  format: Format,
  options: Except<ConvertOptions, 'outputPath'> & {defaultFileName: string},
  encoding: Encoding = Encoding.h264
) => {
  if (!converters.has(encoding)) {
    throw new Error(`Unsupported encoding: ${encoding}`);
  }

  const converter = converters.get(encoding)?.get(format);

  if (!converter) {
    throw new Error(`Unsupported file format for ${encoding}: ${format}`);
  }

  track(`file/export/encoding/${encoding}`);
  track(`file/export/format/${format}`);

  const conversionOptions = {
    outputPath: path.join(tempy.directory(), `${options.defaultFileName}.${getFormatExtension(format)}`),
    ...options
  };

  if (options.editService) {
    const croppingHandler = croppingHandlers.get(encoding);

    if (!croppingHandler) {
      throw new Error(`Unsupported encoding: ${encoding}`);
    }

    return convertWithEditPlugin({...conversionOptions, format, croppingHandler, converter});
  }

  return converter(conversionOptions);
};

const convertWithEditPlugin = PCancelable.fn(
  async (
    options: ConvertOptions & {
      format: Format;
      converter: (options: ConvertOptions) => PCancelable<string>;
      croppingHandler: (options: ConvertOptions) => PCancelable<string>;
    },
    onCancel: OnCancelFunction
  ) => {
    let croppedPath: string;
    let isCanceled = false;

    if (options.shouldCrop) {
      croppedPath = tempy.file({extension: path.extname(options.inputPath)});

      options.onProgress('Cropping', 0);

      const cropProcess = options.croppingHandler({
        ...options,
        outputPath: croppedPath
      });

      onCancel(() => {
        isCanceled = true;
        cropProcess.cancel();
      });

      await cropProcess;

      if (isCanceled) {
        return '';
      }
    } else {
      croppedPath = options.inputPath;
    }

    // eslint-disable-next-line @typescript-eslint/promise-function-async
    const convertFunction = (args: string[], text = 'Converting') => new PCancelable<void>(async (resolve, reject, onCancel) => {
      try {
        const process = convert(
          '', {
            shouldTrack: false,
            startTime: options.startTime,
            endTime: options.endTime,
            onProgress: (progress, estimate) => {
              options.onProgress(text, progress, estimate);
            }
          }, args
        );

        onCancel(() => {
          process.cancel();
        });
        await process;
        resolve();
      } catch (error) {
        reject(error);
      }
    });

    const editPath = tempy.file({extension: path.extname(croppedPath)});

    const editPlugin = plugins.editPlugins.find(plugin => {
      return plugin.name === options.editService?.pluginName;
    });

    const editService = editPlugin?.editServices.find(service => {
      return service.title === options.editService?.serviceTitle;
    });

    if (!editService || !editPlugin) {
      throw new Error(`Edit service ${options.editService?.serviceTitle} not found`);
    }

    const editProcess = editService.action(
      new EditServiceContext({
        plugin: editPlugin,
        onCancel: options.onCancel,
        onProgress: options.onProgress,
        convert: convertFunction,
        inputPath: croppedPath,
        outputPath: editPath,
        exportOptions: {
          width: options.width,
          height: options.height,
          format: options.format,
          fps: options.fps,
          duration: options.endTime - options.startTime,
          isMuted: options.shouldMute,
          loop: settings.get('loopExports')
        }
      })
    );

    onCancel(() => {
      isCanceled = true;
      // @ts-expect-error
      if (editProcess.cancel && typeof editProcess.cancel === 'function') {
        (editProcess as PCancelable<void>).cancel();
      }
    });

    await editProcess;

    if (isCanceled) {
      return '';
    }

    track(`plugins/used/edit/${options.editService?.pluginName}`);

    const conversionProcess = options.converter({
      ...options,
      shouldCrop: false,
      inputPath: editPath
    });

    onCancel(() => {
      conversionProcess.cancel();
    });

    return conversionProcess;
  }
);


================================================
FILE: main/converters/process.ts
================================================
import util from 'electron-util';
import execa from 'execa';
import moment from 'moment';
import PCancelable from 'p-cancelable';
import tempy from 'tempy';
import path from 'path';

import {track} from '../common/analytics';
import {conditionalArgs, extractProgressFromStderr} from './utils';
import {settings} from '../common/settings';

import ffmpegPath from '../utils/ffmpeg-path';

const gifsicle = require('gifsicle');
const gifsiclePath = util.fixPathForAsarUnpack(gifsicle);

enum Mode {
  convert,
  compress
}

const modes = new Map([
  [Mode.convert, ffmpegPath],
  [Mode.compress, gifsiclePath]
]);

export interface ProcessOptions {
  shouldTrack?: boolean;
  startTime?: number;
  endTime?: number;
  onProgress?: (progress: number, estimate?: string) => void;
}

const defaultProcessOptions = {
  shouldTrack: true
};

const createProcess = (mode: Mode) => {
  const program = modes.get(mode)!;

  // eslint-disable-next-line @typescript-eslint/promise-function-async
  return (outputPath: string, options: ProcessOptions, args: string[]) => {
    const {
      shouldTrack,
      startTime = 0,
      endTime = 0,
      onProgress
    } = {
      ...defaultProcessOptions,
      ...options
    };

    const modeName = Mode[mode];
    const trackConversionEvent = (eventName: string) => {
      if (shouldTrack) {
        track(`file/export/${modeName}/${eventName}`);
      }
    };

    return new PCancelable<string>((resolve, reject, onCancel) => {
      const runner = execa(program, args);
      const conversionStartTime = Date.now();

      onCancel(() => {
        trackConversionEvent('canceled');
        runner.kill();
      });

      const durationMs = moment.duration(endTime - startTime, 'seconds').asMilliseconds();

      let stderr = '';
      runner.stderr?.setEncoding('utf8');
      runner.stderr?.on('data', data => {
        stderr += data;

        const progressData = extractProgressFromStderr(data, conversionStartTime, durationMs);

        if (progressData) {
          onProgress?.(progressData.progress, progressData.estimate);
        }
      });

      const failWithError = (reason: unknown) => {
        trackConversionEvent('failed');
        reject(reason);
      };

      runner.on('error', failWithError);

      runner.on('exit', code => {
        if (code === 0) {
          trackConversionEvent('completed');
          resolve(outputPath);
        } else {
          failWithError(new Error(`${program} exited with code: ${code ?? 0}\n\n${stderr}`));
        }
      });

      runner.catch(failWithError);
    });
  };
};

export const convert = createProcess(Mode.convert);
const compressFunction = createProcess(Mode.compress);

// eslint-disable-next-line @typescript-eslint/promise-function-async
export const compress = (outputPath: string, options: ProcessOptions, args: string[]) => {
  const useLossy = settings.get('lossyCompression', false);

  return compressFunction(
    outputPath,
    options,
    conditionalArgs(args, {args: ['--lossy=50'], if: useLossy})
  );
};

export const mute = PCancelable.fn(async (inputPath: string, onCancel: PCancelable.OnCancelFunction) => {
  const mutedPath = tempy.file({extension: path.extname(inputPath)});

  const converter = convert(mutedPath, {shouldTrack: false}, [
    '-i',
    inputPath,
    '-an',
    '-vcodec',
    'copy',
    mutedPath
  ]);

  onCancel(() => {
    converter.cancel();
  });

  return converter;
});


================================================
FILE: main/converters/utils.ts
================================================
import moment from 'moment';
import prettyMilliseconds from 'pretty-ms';

export interface ConvertOptions {
  inputPath: string;
  outputPath: string;
  shouldCrop: boolean;
  startTime: number;
  endTime: number;
  width: number;
  height: number;
  fps: number;
  shouldMute: boolean;
  onCancel: () => void;
  onProgress: (action: string, progress: number, estimate?: string) => void;
  editService?: {
    pluginName: string;
    serviceTitle: string;
  };
}

export const makeEven = (number: number) => 2 * Math.round(number / 2);

export const areDimensionsEven = ({width, height}: {width: number; height: number}) => width % 2 === 0 && height % 2 === 0;

export const extractProgressFromStderr = (stderr: string, conversionStartTime: number, durationMs: number) => {
  const conversionDuration = Date.now() - conversionStartTime;
  const data = stderr.trim();

  const speed = Number.parseFloat(/speed=\s*(-?\d+(,\d+)*(\.\d+(e\d+)?)?)/gm.exec(data)?.[1] ?? '0');
  const processedMs = moment.duration(/time=\s*(\d\d:\d\d:\d\d.\d\d)/gm.exec(data)?.[1] ?? 0).asMilliseconds();

  if (speed > 0) {
    const progress = processedMs / durationMs;

    // Wait 2 seconds in the conversion for speed to be stable
    // Either 2 seconds of the video or 15 seconds real time (for super slow conversion like AV1)
    if (processedMs > 2 * 1000 || conversionDuration > 15 * 1000) {
      const msRemaining = (durationMs - processedMs) / speed;

      return {
        progress,
        estimate: prettyMilliseconds(Math.max(msRemaining, 1000), {compact: true})
      };
    }

    return {progress};
  }

  return undefined;
};

type ArgType = string[] | string | {args: string[]; if: boolean};

// Resolve conditional args
//
// conditionalArgs(['default', 'args'], {args: ['ignore', 'these'], if: false});
// => ['default', 'args']
export const conditionalArgs = (...args: ArgType[]): string[] => {
  return args.flatMap(arg => {
    if (typeof arg === 'string') {
      return [arg];
    }

    if (Array.isArray(arg)) {
      return arg;
    }

    return arg.if ? arg.args : [];
  });
};


================================================
FILE: main/export.ts
================================================
import {ipcMain, dialog, app} from 'electron';
import {EventEmitter} from 'events';
import PCancelable, {CancelError, OnCancelFunction} from 'p-cancelable';
import Conversion from './conversion';
import {InstalledPlugin} from './plugins/plugin';
import {ShareService} from './plugins/service';
import {ShareServiceContext} from './plugins/service-context';
import {prettifyFormat} from './utils/formats';
import {ipcMain as ipc} from 'electron-better-ipc';
import {setExportMenuItemState} from './menus/utils';
import {Video} from './video';
import {ConversionOptions, ExportState, ExportStatus, Format, CreateExportOptions} from './common/types';
import {showError} from './utils/errors';
import TypedEventEmitter from 'typed-emitter';
import {plugins} from './plugins';
import {askForTargetFilePath} from './plugins/built-in/save-file-plugin';
import path from 'path';
import {ensureDockIsShowingSync} from './utils/dock';
import {windowManager} from './windows/manager';

export interface ExportOptions {
  plugin: InstalledPlugin;
  service: ShareService;
  extras: Record<string, unknown>;
}

export default class Export extends (EventEmitter as new () => TypedEventEmitter<ExportEvents>) {
  static exportsMap = new Map<string, Export>();
  static events = new EventEmitter() as TypedEventEmitter<ExportsEvents>;

  static get all() {
    return [...this.exportsMap.values()];
  }

  readonly createdAt: number = Date.now();
  conversion?: Conversion;
  status: ExportStatus = ExportStatus.inProgress;

  private text = 'Loading…';
  private percentage = 0;

  private readonly context: ShareServiceContext;
  private process?: PCancelable<void>;
  private areOutputActionsDisabled = false;
  private error?: Error;
  private readonly description: string;

  private readonly _start = PCancelable.fn(async (onCancel: OnCancelFunction) => {
    this.error = undefined;
    this.text = 'Loading…';
    const action = this.options.service.action(this.context) as any;

    onCancel(() => {
      if (action.cancel && typeof action.cancel === 'function') {
        action.cancel();
      }

      this.context.isCanceled = true;
    });

    try {
      await action;
      this.status = ExportStatus.completed;
      this.text = 'Export completed';
      this.emit('updated', this.data);
    } catch (error) {
      this.captureError(error as any);
    }
  });

  constructor(
    public readonly video: Video,
    private readonly format: Format,
    private readonly conversionOptions: ConversionOptions,
    private readonly options: ExportOptions,
    private readonly title: string = video.title
  ) {
    // eslint-disable-next-line constructor-super
    super();
    Export.addExport(this);
    video.generatePreviewImage();

    this.description = `${this.conversionOptions.width} x ${this.conversionOptions.height} at ${this.conversionOptions.fps} FPS`;

    this.context = new ShareServiceContext({
      plugin: options.plugin,
      format,
      prettyFormat: prettifyFormat(format),
      defaultFileName: video.title,
      filePath: this.filePath,
      onProgress: this.onProgress,
      onCancel: this.cancel
    });

    // Used for built-in plugins like save-to-disk
    for (const [key, value] of Object.entries(options.extras)) {
      (this.context as any)[key] = value;
    }

    setExportMenuItemState(true);
  }

  static addExport = (newExport: Export) => {
    Export.exportsMap.set(newExport.id, newExport);
    Export.events.emit('added', newExport.data);

    newExport.on('updated', state => Export.events.emit('updated', state));
  };

  static fromId(id: string) {
    return this.exportsMap.get(id);
  }

  get id() {
    return this.createdAt.toString();
  }

  get canPreviewExport() {
    return [Format.gif, Format.apng].includes(this.format) && this.finalFilePath !== undefined;
  }

  get finalFilePath() {
    const filePath = this.conversion?.convertedFilePath;

    // If Save To Disk plugin is used, open the file in the final destination, not the temp one
    return filePath && ((this.options.extras.targetFilePath as string) ?? filePath);
  }

  get data(): ExportState {
    return {
      title: this.title,
      titleWithFormat: `${this.title}.${this.format}`,
      description: this.description,
      canCopy: this.conversion?.canCopy ?? false,
      status: this.status,
      message: this.text,
      progress: this.percentage ?? 0,
      image: this.video.previewImage?.data,
      id: this.id,
      filePath: this.conversion?.convertedFilePath,
      error: this.error,
      fileSize: this.conversion?.finalSize,
      disableOutputActions: this.areOutputActionsDisabled,
      canPreviewExport: this.canPreviewExport
    };
  }

  filePath = async ({fileType}: {fileType?: Format} = {}) => {
    if (fileType) {
      this.areOutputActionsDisabled = true;
    }

    const format = fileType ?? this.format;

    this.conversion = Conversion.getOrCreate(this.video, format, this.conversionOptions);
    this.setupConversionListeners();

    return this.conversion.filePath();
  };

  start = async () => {
    try {
      this.process = this._start();
      await this.process;
    } catch (error) {
      this.captureError(error as any);
    }
  };

  onProgress = (text: string, percentage: number) => {
    if (this.status !== ExportStatus.inProgress) {
      return;
    }

    this.text = text;
    this.percentage = percentage;
    this.emit('updated', this.data);
  };

  cancel = () => {
    this.process?.cancel();
    this.conversion?.cancel();
    this.status = ExportStatus.canceled;
    this.text = 'Export canceled';
    this.context.isCanceled = true;
    this.emit('updated', this.data);
  };

  retry = () => {
    this.status = ExportStatus.inProgress;
    this.error = undefined;
    this.text = '';
    this.start();
    this.emit('updated', this.data);
  };

  private readonly captureError = (error: Error, fromConversion = false) => {
    if ((error as CancelError).isCanceled) {
      this.text = 'Export canceled';
      this.status = ExportStatus.canceled;
    } else {
      this.text = 'Export failed';
      this.status = ExportStatus.failed;

      if (!this.error) {
        this.error = error;
        showError(error, fromConversion ? undefined : {plugin: this.options.plugin});
      }
    }

    this.emit('updated', this.data);
  };

  private readonly captureConversionError = (error: Error) => this.captureError(error, true);

  private readonly setupConversionListeners = () => {
    this.conversion?.once('file-size', () => this.emit('updated', this.data));

    this.conversion?.on('cancel', this.cancel);
    this.conversion?.on('progress', this.onProgress);
    this.conversion?.on('error', this.captureConversionError);
    this.conversion?.on('completed', this.cleanConversionListeners);
  };

  private readonly cleanConversionListeners = () => {
    this.conversion?.removeListener('cancel', this.cancel);
    this.conversion?.removeListener('progress', this.onProgress);
    this.conversion?.removeListener('error', this.captureConversionError);
  };
}

interface ExportEvents {
  updated: (state: ExportState) => void;
}

interface ExportsEvents {
  added: (state: ExportState) => void;
  updated: (state: ExportState) => void;
}

export const setUpExportsListeners = () => {
  ipcMain.on('drag-export', async (event: any, id: string) => {
    const exportMap = Export.exportsMap.get(id);
    const conversion = exportMap?.conversion;

    if (conversion && (await conversion.filePathExists()) && exportMap?.status === ExportStatus.completed) {
      event.sender.startDrag({
        file: exportMap?.finalFilePath ?? conversion.convertedFilePath,
        icon: await conversion.video.getDragIcon(conversion.options)
      });
    }
  });

  ipc.answerRenderer('create-export', async ({
    filePath, conversionOptions, format, plugins: pluginOptions
  }: CreateExportOptions, window) => {
    const video = Video.fromId(filePath);
    const extras: Record<string, any> = {
      appUrl: pluginOptions.share.app?.url
    };

    if (!video) {
      return;
    }

    if (pluginOptions.share.pluginName === '_saveToDisk') {
      const targetFilePath = await askForTargetFilePath(
        window,
        format,
        video.title
      );

      if (targetFilePath) {
        extras.targetFilePath = targetFilePath;
      } else {
        return;
      }
    }

    const exportPlugin = plugins.sharePlugins.find(plugin => {
      return plugin.name === pluginOptions.share.pluginName;
    });

    const exportService = exportPlugin?.shareServices.find(service => {
      return service.title === pluginOptions.share.serviceTitle;
    });

    if (!exportPlugin || !exportService) {
      return;
    }

    const newExport = new Export(
      video,
      format,
      conversionOptions,
      {
        plugin: exportPlugin,
        service: exportService,
        extras
      },
      extras.targetFilePath && path.parse(extras.targetFilePath).name
    );

    newExport.start();

    return newExport.id;
  });

  app.on('before-quit', event => {
    if (Export.all.some(exp => exp.status === ExportStatus.inProgress)) {
      windowManager.exports?.open();

      ensureDockIsShowingSync(() => {
        const buttonIndex = dialog.showMessageBoxSync({
          type: 'question',
          buttons: [
            'Continue',
            'Quit'
          ],
          defaultId: 0,
          cancelId: 1,
          message: 'Do you want to continue exporting?',
          detail: 'Kap is currently exporting files. If you quit, the export task will be canceled.'
        });

        if (buttonIndex === 0) {
          event.preventDefault();
        }
      });
    }
  });
};


================================================
FILE: main/global-accelerators.ts
================================================
import {globalShortcut} from 'electron';
import {ipcMain as ipc} from 'electron-better-ipc';
import {settings} from './common/settings';
import {windowManager} from './windows/manager';

const openCropper = () => {
  if (!windowManager.cropper?.isOpen()) {
    windowManager.cropper?.open();
  }
};

// All settings that should be loaded and handled as global accelerators
const handlers = new Map<string, () => void>([
  ['triggerCropper', openCropper]
]);

// If no action is passed, it resets
export const setCropperShortcutAction = (action = openCropper) => {
  if (settings.get('enableShortcuts') && settings.get('shortcuts.triggerCropper')) {
    handlers.set('cropperShortcut', action);

    const shortcut = settings.get<string, string>('shortcuts.triggerCropper');
    if (globalShortcut.isRegistered(shortcut)) {
      globalShortcut.unregister(shortcut);
    }

    globalShortcut.register(shortcut, action);
  }
};

const registerShortcut = (shortcut: string, action: () => void) => {
  try {
    globalShortcut.register(shortcut, action);
  } catch (error) {
    console.error('Error registering shortcut', shortcut, action, error);
  }
};

const registerFromStore = () => {
  if (settings.get('enableShortcuts')) {
    for (const [setting, action] of handlers.entries()) {
      const shortcut = settings.get<string, string>(`shortcuts.${setting}`);
      if (shortcut) {
        registerShortcut(shortcut, action);
      }
    }
  } else {
    globalShortcut.unregisterAll();
  }
};

export const initializeGlobalAccelerators = () => {
  ipc.answerRenderer('update-shortcut', ({setting, shortcut}) => {
    const oldShortcut = settings.get<string, string>(`shortcuts.${setting}`);

    try {
      if (oldShortcut && oldShortcut !== shortcut && globalShortcut.isRegistered(oldShortcut)) {
        globalShortcut.unregister(oldShortcut);
      }
    } catch (error) {
      console.error('Error unregistering old shortcutAccelerator', error);
    } finally {
      if (shortcut && shortcut !== oldShortcut) {
        settings.set(`shortcuts.${setting}`, shortcut);
        const handler = handlers.get(setting);

        if (settings.get('enableShortcuts') && handler) {
          registerShortcut(shortcut, handler);
        }
      } else if (!shortcut) {
        // @ts-expect-error
        settings.delete(`shortcuts.${setting}`);
      }
    }
  });

  ipc.answerRenderer('toggle-shortcuts', ({enabled}) => {
    if (enabled) {
      registerFromStore();
    } else {
      globalShortcut.unregisterAll();
    }
  });

  // Register keyboard shortcuts from store
  registerFromStore();
};


================================================
FILE: main/index.ts
================================================
import {app} from 'electron';
import {is, enforceMacOSAppLocation} from 'electron-util';
import log from 'electron-log';
import {autoUpdater} from 'electron-updater';
import toMilliseconds from '@sindresorhus/to-milliseconds';

import './windows/load';
import './utils/sentry';

require('electron-timber').hookConsole({main: true, renderer: true});

import {settings} from './common/settings';
import {plugins} from './plugins';
import {initializeTray} from './tray';
import {initializeDevices} from './utils/devices';
import {initializeAnalytics, track} from './common/analytics';
import {initializeGlobalAccelerators} from './global-accelerators';
import {openFiles} from './utils/open-files';
import {hasMicrophoneAccess, ensureScreenCapturePermissions} from './common/system-permissions';
import {handleDeepLink} from './utils/deep-linking';
import {hasActiveRecording, cleanPastRecordings} from './recording-history';
import {setupRemoteStates} from './remote-states';
import {setUpExportsListeners} from './export';
import {windowManager} from './windows/manager';
import {setupProtocol} from './utils/protocol';
import {stopRecordingWithNoEdit} from './aperture';

const prepareNext = require('electron-next');

const filesToOpen: string[] = [];

let onExitCleanupComplete = false;

app.commandLine.appendSwitch('--enable-features', 'OverlayScrollbar');

app.on('open-file', (event, path) => {
  event.preventDefault();

  if (app.isReady()) {
    track('editor/opened/running');
    openFiles(path);
  } else {
    filesToOpen.push(path);
  }
});

const initializePlugins = async () => {
  if (!is.development) {
    try {
      await plugins.upgrade();
    } catch (error) {
      console.log(error);
    }
  }
};

const checkForUpdates = () => {
  if (is.development) {
    return false;
  }

  const checkForUpdates = async () => {
    try {
      await autoUpdater.checkForUpdates();
    } catch (error) {
      autoUpdater.logger?.error(error);
    }
  };

  // For auto-update debugging in Console.app
  autoUpdater.logger = log;
  // @ts-expect-error
  autoUpdater.logger.transports.file.level = 'info';

  setInterval(checkForUpdates, toMilliseconds({hours: 1}));

  checkForUpdates();
  return true;
};

// Prepare the renderer once the app is ready
(async () => {
  await app.whenReady();
  require('./utils/errors').setupErrorHandling();

  // Initialize remote states
  setupRemoteStates();

  setupProtocol();

  app.dock.hide();
  app.setAboutPanelOptions({copyright: 'Copyright © Wulkano'});

  // Ensure the app is in the Applications folder
  enforceMacOSAppLocation();

  await prepareNext('./renderer');

  // Ensure all plugins are up to date
  initializePlugins();
  initializeDevices();
  initializeAnalytics();
  initializeTray();
  initializeGlobalAccelerators();
  setUpExportsListeners();

  if (!app.isDefaultProtocolClient('kap')) {
    app.setAsDefaultProtocolClient('kap');
  }

  if (filesToOpen.length > 0) {
    track('editor/opened/startup');
    openFiles(...filesToOpen);
    hasActiveRecording();
  } else if (
    !(await hasActiveRecording()) &&
    !app.getLoginItemSettings().wasOpenedAtLogin &&
    ensureScreenCapturePermissions() &&
    (!settings.get('recordAudio') || hasMicrophoneAccess())
  ) {
    windowManager.cropper?.open();
  }

  checkForUpdates();
})();

app.on('window-all-closed', (event: any) => {
  app.dock.hide();
  event.preventDefault();
});

app.on('will-finish-launching', () => {
  app.on('open-url', (event, url) => {
    event.preventDefault();
    handleDeepLink(url);
  });
});

app.on('before-quit', async (event: any) => {
  if (!onExitCleanupComplete) {
    event.preventDefault();
    await stopRecordingWithNoEdit();
    cleanPastRecordings();
    onExitCleanupComplete = true;
    app.quit();
  }
});


================================================
FILE: main/menus/application.ts
================================================
import {appMenu} from 'electron-util';
import {getAboutMenuItem, getExportHistoryMenuItem, getOpenFileMenuItem, getPreferencesMenuItem, getSendFeedbackMenuItem} from './common';
import {MenuItemId, MenuOptions} from './utils';

const getAppMenuItem = () => {
  const appMenuItem = appMenu([getPreferencesMenuItem()]);

  // @ts-expect-error
  appMenuItem.submenu[0] = getAboutMenuItem();
  return {...appMenuItem, id: MenuItemId.app};
};

// eslint-disable-next-line unicorn/prevent-abbreviations
export const defaultApplicationMenu = (): MenuOptions => [
  getAppMenuItem(),
  {
    role: 'fileMenu',
    id: MenuItemId.file,
    submenu: [
      getOpenFileMenuItem(),
      {
        type: 'separator'
      },
      {
        role: 'close'
      }
    ]
  },
  {
    role: 'editMenu',
    id: MenuItemId.edit
  },
  {
    role: 'windowMenu',
    id: MenuItemId.window,
    submenu: [
      {
        role: 'minimize'
      },
      {
        role: 'zoom'
      },
      {
        type: 'separator'
      },
      getExportHistoryMenuItem(),
      {
        type: 'separator'
      },
      {
        role: 'front'
      }
    ]
  },
  {
    id: MenuItemId.help,
    label: 'Help',
    role: 'help',
    submenu: [getSendFeedbackMenuItem()]
  }
];

// eslint-disable-next-line unicorn/prevent-abbreviations
export const customApplicationMenu = (modifier: (defaultMenu: ReturnType<typeof defaultApplicationMenu>) => void) => {
  const menu = defaultApplicationMenu();
  modifier(menu);
  return menu;
};

export type MenuModifier = Parameters<typeof customApplicationMenu>[0];


================================================
FILE: main/menus/cog.ts
================================================
import {Menu} from 'electron';
import {MenuItemId, MenuOptions} from './utils';
import {getAboutMenuItem, getExportHistoryMenuItem, getOpenFileMenuItem, getPreferencesMenuItem, getSendFeedbackMenuItem} from './common';
import {plugins} from '../plugins';
import {getAudioDevices, getDefaultInputDevice} from '../utils/devices';
import {settings} from '../common/settings';
import {defaultInputDeviceId} from '../common/constants';
import {hasMicrophoneAccess} from '../common/system-permissions';

const getCogMenuTemplate = async (): Promise<MenuOptions> => [
  getAboutMenuItem(),
  {
    type: 'separator'
  },
  getPreferencesMenuItem(),
  {
    type: 'separator'
  },
  getPluginsItem(),
  await getMicrophoneItem(),
  {
    type: 'separator'
  },
  getOpenFileMenuItem(),
  getExportHistoryMenuItem(),
  {
    type: 'separator'
  },
  getSendFeedbackMenuItem(),
  {
    type: 'separator'
  },
  {
    role: 'quit',
    accelerator: 'Command+Q'
  }
];

const getPluginsItem = (): MenuOptions[number] => {
  const items = plugins.recordingPlugins.flatMap(plugin =>
    plugin.recordServicesWithStatus.map(service => ({
      label: service.title,
      type: 'checkbox' as const,
      checked: service.isEnabled,
      click: async () => service.setEnabled(!service.isEnabled)
    }))
  );

  return {
    id: MenuItemId.plugins,
    label: 'Plugins',
    submenu: items,
    visible: items.length > 0
  };
};

const getMicrophoneItem = async (): Promise<MenuOptions[number]> => {
  const devices = await getAudioDevices();
  const isRecordAudioEnabled = settings.get('recordAudio');
  const currentDefaultDevice = getDefaultInputDevice();

  let audioInputDeviceId = settings.get('audioInputDeviceId');
  if (!devices.some(device => device.id === audioInputDeviceId)) {
    settings.set('audioInputDeviceId', defaultInputDeviceId);
    audioInputDeviceId = defaultInputDeviceId;
  }

  return {
    id: MenuItemId.audioDevices,
    label: 'Microphone',
    submenu: [
      {
        label: 'None',
        type: 'checkbox',
        checked: !isRecordAudioEnabled,
        click: () => {
          settings.set('recordAudio', false);
        }
      },
      ...[
        {name: `System Default${currentDefaultDevice ? ` (${currentDefaultDevice.name})` : ''}`, id: defaultInputDeviceId},
        ...devices
      ].map(device => ({
        label: device.name,
        type: 'checkbox' as const,
        checked: isRecordAudioEnabled && (audioInputDeviceId === device.id),
        click: () => {
          settings.set('recordAudio', true);
          settings.set('audioInputDeviceId', device.id);
        }
      }))
    ],
    visible: hasMicrophoneAccess()
  };
};

export const getCogMenu = async () => {
  return Menu.buildFromTemplate(
    await getCogMenuTemplate()
  );
};


================================================
FILE: main/menus/common.ts
================================================
import delay from 'delay';
import {app, dialog} from 'electron';
import {openNewGitHubIssue} from 'electron-util';
import macosRelease from '../utils/macos-release';
import {supportedVideoExtensions} from '../common/constants';
import {getCurrentMenuItem, MenuItemId} from './utils';
import {openFiles} from '../utils/open-files';
import {windowManager} from '../windows/manager';

export const getPreferencesMenuItem = () => ({
  id: MenuItemId.preferences,
  label: 'Preferences…',
  accelerator: 'Command+,',
  click: () => windowManager.preferences?.open()
});

export const getAboutMenuItem = () => ({
  id: MenuItemId.about,
  label: `About ${app.name}`,
  click: () => {
    windowManager.cropper?.close();
    app.focus();
    app.showAboutPanel();
  }
});

export const getOpenFileMenuItem = () => ({
  id: MenuItemId.openVideo,
  label: 'Open Video…',
  accelerator: 'Command+O',
  click: async () => {
    windowManager.cropper?.close();

    await delay(200);

    app.focus();
    const {canceled, filePaths} = await dialog.showOpenDialog({
      filters: [{name: 'Videos', extensions: supportedVideoExtensions}],
      properties: ['openFile', 'multiSelections']
    });

    if (!canceled && filePaths) {
      openFiles(...filePaths);
    }
  }
});

export const getExportHistoryMenuItem = () => ({
  label: 'Export History',
  click: () => windowManager.exports?.open(),
  enabled: getCurrentMenuItem(MenuItemId.exportHistory)?.enabled ?? false,
  id: MenuItemId.exportHistory
});

export const getSendFeedbackMenuItem = () => ({
  id: MenuItemId.sendFeedback,
  label: 'Send Feedback…',
  click() {
    openNewGitHubIssue({
      user: 'wulkano',
      repo: 'kap',
      body: issueBody
    });
  }
});

const release = macosRelease();

const issueBody = `
<!--
Thank you for helping us test Kap. Your feedback helps us make Kap better for everyone!

Before you continue; please make sure you've searched our existing issues to avoid duplicates. When you're ready to open a new issue, include as much information as possible. You can use the handy template below for bug reports.

Step to reproduce:    If applicable, provide steps to reproduce the issue you're having.
Current behavior:     A description of how Kap is currently behaving.
Expected behavior:    How you expected Kap to behave.
Workaround:           A workaround for the issue if you've found on. (this will help others experiencing the same issue!)
-->

**macOS version:**    ${release.name} (${release.version})
**Kap version:**      ${app.getVersion()}

#### Steps to reproduce

#### Current behavior

#### Expected behavior

#### Workaround

<!-- If you have additional information, enter it below. -->
`;



================================================
FILE: main/menus/record.ts
================================================
import {Menu} from 'electron';
import {MenuItemId, MenuOptions} from './utils';
import {pauseRecording, resumeRecording, stopRecording} from '../aperture';
import formatTime from '../utils/format-time';
import {getCurrentDurationStart, getOverallDuration} from '../utils/track-duration';

const getDurationLabel = () => {
  if (getCurrentDurationStart() <= 0) {
    return formatTime((getOverallDuration()) / 1000, undefined);
  }

  return formatTime((getOverallDuration() + (Date.now() - getCurrentDurationStart())) / 1000, undefined);
};

const getDurationMenuItem = () => ({
  id: MenuItemId.duration,
  label: getDurationLabel(),
  enabled: false
});

const getStopRecordingMenuItem = () => ({
  id: MenuItemId.stopRecording,
  label: 'Stop',
  click: stopRecording
});

const getPauseRecordingMenuItem = () => ({
  id: MenuItemId.pauseRecording,
  label: 'Pause',
  click: pauseRecording
});

const getResumeRecordingMenuItem = () => ({
  id: MenuItemId.resumeRecording,
  label: 'Resume',
  click: resumeRecording
});

export const getRecordMenuTemplate = (isPaused: boolean): MenuOptions => [
  getDurationMenuItem(),
  {
    type: 'separator'
  },
  isPaused ? getResumeRecordingMenuItem() : getPauseRecordingMenuItem(),
  getStopRecordingMenuItem(),
  {
    type: 'separator'
  },
  {
    role: 'quit',
    accelerator: 'Command+Q'
  }
];

export const getRecordMenu = async (isPaused: boolean) => {
  return Menu.buildFromTemplate(getRecordMenuTemplate(isPaused));
};


================================================
FILE: main/menus/utils.ts
================================================
import {Menu} from 'electron';

export type MenuOptions = Parameters<typeof Menu.buildFromTemplate>[0];

export enum MenuItemId {
  exportHistory = 'exportHistory',
  sendFeedback = 'sendFeedback',
  openVideo = 'openVideo',
  about = 'about',
  preferences = 'preferences',
  file = 'file',
  edit = 'edit',
  window = 'window',
  help = 'help',
  app = 'app',
  saveOriginal = 'saveOriginal',
  plugins = 'plugins',
  audioDevices = 'audioDevices',
  stopRecording = 'stopRecording',
  pauseRecording = 'pauseRecording',
  resumeRecording = 'resumeRecording',
  duration = 'duration'
}

export const getCurrentMenuItem = (id: MenuItemId) => {
  return Menu.getApplicationMenu()?.getMenuItemById(id);
};

export const setExportMenuItemState = (enabled: boolean) => {
  const menuItem = Menu.getApplicationMenu()?.getMenuItemById(MenuItemId.exportHistory);

  if (menuItem) {
    menuItem.enabled = enabled;
  }
};


================================================
FILE: main/plugins/built-in/copy-to-clipboard-plugin.ts
================================================
import {clipboard} from 'electron';
import {ShareServiceContext} from '../service-context';

const plist = require('plist');

const copyFileReferencesToClipboard = (filePaths: string[]) => {
  clipboard.writeBuffer('NSFilenamesPboardType', Buffer.from(plist.build(filePaths)));
};

const action = async (context: ShareServiceContext) => {
  const filePath = await context.filePath();
  copyFileReferencesToClipboard([filePath]);
  context.notify(`The ${context.prettyFormat} has been copied to the clipboard`);
};

const copyToClipboard = {
  title: 'Copy to Clipboard',
  formats: [
    'gif',
    'apng',
    'mp4'
  ],
  action
};

export const shareServices = [copyToClipboard];


================================================
FILE: main/plugins/built-in/open-with-plugin.ts
================================================
import {ShareServiceContext} from '../service-context';
import path from 'path';
import {getFormatExtension} from '../../common/constants';
import {Format} from '../../common/types';

const {getAppsThatOpenExtension, openFileWithApp} = require('mac-open-with');

const action = async (context: ShareServiceContext & {appUrl: string}) => {
  const filePath = await context.filePath();
  openFileWithApp(filePath, context.appUrl);
};

export interface App {
  url: string;
  isDefault: boolean;
  icon: string;
  name: string;
}

const getAppsForFormat = (format: Format) => {
  return (getAppsThatOpenExtension.sync(getFormatExtension(format)) as App[])
    .map(app => ({...app, name: decodeURI(path.parse(app.url).name)}))
    .filter(app => !['Kap', 'Kap Beta'].includes(app.name))
    .sort((a, b) => {
      if (a.isDefault !== b.isDefault) {
        return Number(b.isDefault) - Number(a.isDefault);
      }

      return Number(b.name === 'Gifski') - Number(a.name === 'Gifski');
    });
};

const appsForFormat = (['mp4', 'gif', 'apng', 'webm', 'av1', 'hevc'] as Format[])
  .map(format => ({
    format,
    apps: getAppsForFormat(format)
  }))
  .filter(({apps}) => apps.length > 0);

export const apps = new Map(appsForFormat.map(({format, apps}) => [format, apps]));

export const shareServices = [{
  title: 'Open With',
  formats: [...apps.keys()],
  action
}];


================================================
FILE: main/plugins/built-in/save-file-plugin.ts
================================================
'use strict';

import {BrowserWindow, dialog} from 'electron';
import {ShareServiceContext} from '../service-context';
import {settings} from '../../common/settings';
import makeDir from 'make-dir';
import {Format} from '../../common/types';
import path from 'path';

const {Notification, shell} = require('electron');
const cpFile = require('cp-file');

const action = async (context: ShareServiceContext & {targetFilePath: string}) => {
  const temporaryFilePath = await context.filePath();

  // Execution has been interrupted
  if (context.isCanceled) {
    return;
  }

  // Copy the file, so we can still use the temporary source for future exports
  // The temporary file will be cleaned up on app exit, or automatic system cleanup
  await cpFile(temporaryFilePath, context.targetFilePath);

  const notification = new Notification({
    title: 'File saved successfully!',
    body: 'Click to show the file in Finder'
  });

  notification.on('click', () => {
    shell.showItemInFolder(context.targetFilePath);
  });

  notification.show();
};

const saveFile = {
  title: 'Save to Disk',
  formats: [
    'gif',
    'mp4',
    'webm',
    'apng',
    'av1',
    'hevc'
  ],
  action
};

export const shareServices = [saveFile];

const filterMap = new Map([
  [Format.mp4, [{name: 'Movies', extensions: ['mp4']}]],
  [Format.webm, [{name: 'Movies', extensions: ['webm']}]],
  [Format.gif, [{name: 'Images', extensions: ['gif']}]],
  [Format.apng, [{name: 'Images', extensions: ['apng']}]],
  [Format.av1, [{name: 'Movies', extensions: ['mp4']}]],
  [Format.hevc, [{name: 'Movies', extensions: ['mp4']}]]
]);

let lastSavedDirectory: string;

export const askForTargetFilePath = async (
  window: BrowserWindow,
  format: Format,
  fileName: string
) => {
  const kapturesDir = settings.get('kapturesDir');
  await makeDir(kapturesDir);

  const defaultPath = path.join(lastSavedDirectory ?? kapturesDir, fileName);

  const filters = filterMap.get(format);

  const {filePath} = await dialog.showSaveDialog(window, {
    title: fileName,
    defaultPath,
    filters
  });

  if (filePath) {
    lastSavedDirectory = path.dirname(filePath);
    return filePath;
  }

  return undefined;
};


================================================
FILE: main/plugins/config.ts
================================================
import {ValidateFunction} from 'ajv';
import Store, {Schema as JSONSchema} from 'electron-store';
import Ajv, {Schema} from '../utils/ajv';
import {Service} from './service';

export default class PluginConfig extends Store {
  servicesWithNoConfig: Service[];
  validators: Array<{
    title: string;
    description?: string;
    config: Record<string, Schema>;
    validate: ValidateFunction;
  }>;

  constructor(name: string, services: Service[]) {
    const defaults = {};

    const validators = services
      .filter(({config}) => Boolean(config))
      .map(service => {
        const config = service.config as Record<string, Schema>;
        const schema: Record<string, JSONSchema<any>> = {};
        const requiredKeys = [];

        for (const key of Object.keys(config)) {
          if (!config[key].title) {
            throw new Error('Config schema items should have a `title`');
          }

          const {required, ...rest} = config[key];

          if (required) {
            requiredKeys.push(key);
          }

          schema[key] = rest;
        }

        const ajv = new Ajv({
          format: 'full',
          useDefaults: true,
          errorDataPath: 'property',
          allErrors: true
        });

        const validator = ajv.compile({
          type: 'object',
          properties: schema,
          required: requiredKeys
        });

        validator(defaults);
        return {
          validate: validator,
          title: service.title,
          description: service.configDescription,
          config
        };
      });

    super({
      name,
      cwd: 'plugins',
      defaults
    });

    this.servicesWithNoConfig = services.filter(({config}) => !config);
    this.validators = validators;
  }

  get isValid() {
    return this.validators.every(validator => validator.validate(this.store));
  }

  get validServices() {
    return [
      ...this.validators.filter(validator => validator.validate(this.store)),
      ...this.servicesWithNoConfig
    ].map(service => service.title);
  }
}


================================================
FILE: main/plugins/index.ts
================================================
import {app} from 'electron';
import {EventEmitter} from 'events';
import path from 'path';
import fs from 'fs';
import makeDir from 'make-dir';
import execa from 'execa';
import {track} from '../common/analytics';
import {InstalledPlugin, NpmPlugin} from './plugin';
import {showError} from '../utils/errors';
import {notify} from '../utils/notifications';
import packageJson from 'package-json';
import {NormalizedPackageJson} from 'read-pkg';
import {windowManager} from '../windows/manager';

const got = require('got');

type PackageJson = {
  dependencies: Record<string, string>;
};

export class Plugins extends EventEmitter {
  yarnBin = path.join(__dirname, '../../node_modules/yarn/bin/yarn.js');
  appVersion = app.getVersion();
  pluginsDir = path.join(app.getPath('userData'), 'plugins');
  builtInDir = path.join(__dirname, 'built-in');
  packageJsonPath = path.join(this.pluginsDir, 'package.json');
  installedPlugins: InstalledPlugin[] = [];
  builtInPlugins = [
    new InstalledPlugin('_copyToClipboard', path.resolve(this.builtInDir, 'copy-to-clipboard-plugin')),
    new InstalledPlugin('_saveToDisk', path.resolve(this.builtInDir, 'save-file-plugin')),
    new InstalledPlugin('_openWith', path.resolve(this.builtInDir, 'open-with-plugin'))
  ];

  constructor() {
    super();
    this.makePluginsDir();
    this.loadPlugins();
  }

  async install(name: string): Promise<InstalledPlugin | void> {
    track(`plugin/installed/${name}`);

    this.modifyMainPackageJson(pkg => {
      if (!pkg.dependencies) {
        pkg.dependencies = {};
      }

      pkg.dependencies[name] = 'latest';
    });

    try {
      await this.yarnInstall();

      const plugin = new InstalledPlugin(name);
      this.installedPlugins.push(plugin);

      if (plugin.content.didInstall && typeof plugin.content.didInstall === 'function') {
        try {
          await plugin.content.didInstall?.(plugin.config);
        } catch (error) {
          showError(error as any, {plugin} as any);
        }
      }

      const {isValid, hasConfig} = plugin;

      // Const openConfig = () => openPrefsWindow({target: {name, action: 'configure'}});
      const openConfig = () => plugin.openConfig();

      const options = (isValid && !hasConfig) ? {
        title: 'Plugin installed',
        body: `"${plugin.prettyName}" is ready for use`
      } : {
        title: plugin.isValid ? 'Plugin installed' : 'Configure plugin',
        body: `"${plugin.prettyName}" ${plugin.isValid ? 'can be configured' : 'requires configuration'}`,
        click: openConfig,
        actions: [
          {type: 'button' as const, text: 'Configure', action: openConfig},
          {type: 'button' as const, text: 'Later'}
        ]
      };

      notify(options);

      const validServices = plugin.config.validServices;

      for (const service of plugin.recordServices) {
        if (!service.willEnable && validServices.includes(service.title)) {
          plugin.enableService(service);
        }
      }

      this.emit('installed', plugin);
      return plugin;
    } catch (error) {
      notify.simple(`Something went wrong while installing ${name}`);
      this.modifyMainPackageJson(pkg => {
        delete pkg.dependencies[name];
      });
      showError(error as any);
    }
  }

  async uninstall(name: string) {
    track(`plugin/uninstalled/${name}`);
    this.modifyMainPackageJson(pkg => {
      delete pkg.dependencies[name];
    });
    const plugin = new InstalledPlugin(name);

    if (plugin.content.willUninstall && typeof plugin.content.willUninstall === 'function') {
      try {
        await plugin.content.willUninstall?.(plugin.config);
      } catch (error) {
        showError(error as any, {plugin} as any);
      }
    }

    this.installedPlugins = this.installedPlugins.filter(plugin => plugin.name !== name);
    plugin.config.clear();
    this.emit('uninstalled', name);

    const json = plugin.json!;

    return new NpmPlugin(json, {
      version: json.kapVersion,
      ...json.kap
    });
  }

  async upgrade() {
    return this.yarnInstall();
  }

  async getFromNpm() {
    const url = 'https://api.npms.io/v2/search?q=keywords:kap-plugin+not:deprecated';
    const response = (await got(url, {json: true})) as {
      body: {results: Array<{package: NormalizedPackageJson}>};
    };
    const installed = this.pluginNames;

    return Promise.all(response.body.results
      .map(x => x.package)
      .filter(x => x.name.startsWith('kap-'))
      .filter(x => !installed.includes(x.name)) // Filter out installed plugins
      .map(async x => {
        const {kap, kapVersion} = await packageJson(x.name, {fullMetadata: true}) as any;
        return new NpmPlugin(x, {
          // Keeping for backwards compatibility
          version: kapVersion,
          ...kap
        });
      }));
  }

  get allPlugins() {
    return [
      ...this.installedPlugins,
      ...this.builtInPlugins
    ];
  }

  get sharePlugins() {
    return this.allPlugins.filter(plugin => plugin.shareServices.length > 0);
  }

  get editPlugins() {
    return this.allPlugins.filter(plugin => plugin.editServices.length > 0);
  }

  get recordingPlugins() {
    return this.allPlugins.filter(plugin => plugin.recordServices.length > 0);
  }

  openPluginConfig = async (pluginName: string) => {
    return windowManager.config?.open(pluginName);
  };

  private makePluginsDir() {
    if (!fs.existsSync(this.packageJsonPath)) {
      makeDir.sync(this.pluginsDir);
      fs.writeFileSync(this.packageJsonPath, JSON.stringify({dependencies: {}}, null, 2));
    }
  }

  private modifyMainPackageJson(modifier: (pkg: PackageJson) => void) {
    const pkg = JSON.parse(fs.readFileSync(this.packageJsonPath, 'utf8'));
    modifier(pkg);
    fs.writeFileSync(this.packageJsonPath, JSON.stringify(pkg, null, 2));
  }

  private async runYarn(...args: string[]) {
    await execa(process.execPath, [this.yarnBin, ...args], {
      cwd: this.pluginsDir,
      env: {
        ELECTRON_RUN_AS_NODE: '1',
        NODE_ENV: 'development'
      }
    });
  }

  private get pluginNames() {
    const pkg = fs.readFileSync(this.packageJsonPath, 'utf8');
    return Object.keys(JSON.parse(pkg).dependencies || {});
  }

  private async yarnInstall() {
    await this.runYarn('install', '--no-lockfile', '--registry', 'https://registry.npmjs.org');
  }

  private loadPlugins() {
    this.installedPlugins = this.pluginNames.map(name => new InstalledPlugin(name));
  }
}

export const plugins = new Plugins();


================================================
FILE: main/plugins/plugin.ts
================================================
import {app, shell} from 'electron';
import macosVersion from 'macos-version';
import semver from 'semver';
import path from 'path';
import fs from 'fs';
import readPkg from 'read-pkg';
import {RecordService, ShareService, EditService} from './service';
import {showError} from '../utils/errors';
import PluginConfig from './config';
import Store from 'electron-store';
import {windowManager} from '../windows/manager';

export const recordPluginServiceState = new Store<Record<string, boolean>>({
  name: 'record-plugin-state',
  defaults: {}
});

class BasePlugin {
  name: string;
  kapVersion?: string;
  macosVersion?: string;
  link?: string;
  json?: readPkg.NormalizedPackageJson;

  constructor(pluginName: string) {
    this.name = pluginName;
  }

  get prettyName() {
    return this.name.replace(/^kap-/, '');
  }

  get isCompatible() {
    return semver.satisfies(app.getVersion(), this.kapVersion ?? '*') && macosVersion.is(this.macosVersion ?? '*');
  }

  get repoUrl() {
    if (!this.link) {
      return '';
    }

    const url = new URL(this.link);
    url.hash = '';
    return url.href;
  }

  get version() {
    return this.json?.version;
  }

  get description() {
    return this.json?.description;
  }

  viewOnGithub() {
    if (this.link) {
      shell.openExternal(this.link);
    }
  }
}

export interface KapPlugin<Config = any> {
  shareServices?: Array<ShareService<Config>>;
  editServices?: Array<EditService<Config>>;
  recordServices?: Array<RecordService<Config>>;

  didConfigChange?: (newValue: Readonly<any> | undefined, oldValue: Readonly<any> | undefined, config: Store<Config>) => void | Promise<void>;
  didInstall?: (config: Store<Config>) => void | Promise<void>;
  willUninstall?: (config: Store<Config>) => void | Promise<void>;
}

export class InstalledPlugin extends BasePlugin {
  isInstalled = true;
  pluginsPath = path.join(app.getPath('userData'), 'plugins');

  pluginPath: string;
  json?: readPkg.NormalizedPackageJson;
  content: KapPlugin;
  config: PluginConfig;
  hasConfig: boolean;
  isBuiltIn: boolean;

  constructor(pluginName: string, customPath?: string) {
    super(pluginName);

    this.pluginPath = customPath ?? path.join(this.pluginsPath, 'node_modules', pluginName);
    this.isBuiltIn = Boolean(customPath);

    if (!this.isBuiltIn) {
      this.json = readPkg.sync({cwd: this.pluginPath});
      this.link = this.json.homepage ?? this.json.links?.homepage;

      // Keeping for backwards compatibility
      this.kapVersion = this.json.kap?.version ?? this.json.kapVersion;
      this.macosVersion = this.json.kap?.macosVersion;
    }

    try {
      this.content = require(this.pluginPath);
      this.config = new PluginConfig(pluginName, this.allServices);
      this.hasConfig = this.allServices.some(({config = {}}) => Object.keys(config).length > 0);

      if (this.content.didConfigChange && typeof this.content.didConfigChange === 'function') {
        this.config.onDidAnyChange((newValue, oldValue) => this.content.didConfigChange?.(newValue, oldValue, this.config));
      }
    } catch (error) {
      showError(error as any, {title: `Something went wrong while loading “${pluginName}”`, plugin: this});

      this.content = {};
      this.config = new PluginConfig(pluginName, []);
      this.hasConfig = false;
    }
  }

  get isSymLink() {
    return fs.lstatSync(this.pluginPath).isSymbolicLink();
  }

  get shareServices() {
    return this.content.shareServices ?? [];
  }

  get editServices() {
    return this.content.editServices ?? [];
  }

  get recordServices() {
    return this.content.recordServices ?? [];
  }

  get allServices() {
    return [
      ...this.shareServices,
      ...this.editServices,
      ...this.recordServices
    ];
  }

  get isValid() {
    return this.config.isValid;
  }

  get recordServicesWithStatus() {
    return this.recordServices.map(service => ({
      ...service,
      isEnabled: recordPluginServiceState.get(this.getRecordServiceKey(service), false),
      setEnabled: this.getSetEnableFunction(service)
    }));
  }

  enableService = (service: RecordService) => {
    recordPluginServiceState.set(this.getRecordServiceKey(service), true);
  };

  openConfig = () => windowManager.config?.open(this.name);

  openConfigInEditor = () => {
    return this.config.openInEditor();
  };

  private readonly getSetEnableFunction = (service: RecordService) => async (enabled: boolean) => {
    const isEnabled = recordPluginServiceState.get(this.getRecordServiceKey(service), false);

    if (isEnabled === enabled) {
      return;
    }

    if (!enabled) {
      recordPluginServiceState.set(this.getRecordServiceKey(service), false);
      return;
    }

    if (!this.config.validServices.includes(service.title)) {
      windowManager.preferences?.open({target: {name: this.name, action: 'configure'}});
      return;
    }

    if (service.willEnable && typeof service.willEnable === 'function') {
      try {
        const canEnable = await service.willEnable();

        if (canEnable) {
          recordPluginServiceState.set(this.getRecordServiceKey(service), true);
        }
      } catch (error) {
        showError(error as any, {title: `Something went wrong while enabling "${service.title}`, plugin: this});
      }
    } else {
      recordPluginServiceState.set(this.getRecordServiceKey(service), true);
    }
  };

  private readonly getRecordServiceKey = (service: RecordService) => `${this.name}-${service.title}`;
}

export class NpmPlugin extends BasePlugin {
  isInstalled = false;

  constructor(json: readPkg.NormalizedPackageJson, kap: {version?: string; macosVersion?: string} = {}) {
    super(json.name);

    this.json = json;
    this.kapVersion = kap.version;
    this.macosVersion = kap.macosVersion;
    this.link = this.json.homepage ?? this.json.links?.homepage;
  }
}


================================================
FILE: main/plugins/service-context.ts
================================================
import {app, clipboard} from 'electron';
import Store from 'electron-store';
import got, {GotFn, GotPromise} from 'got';
import {ApertureOptions, Format} from '../common/types';
import {InstalledPlugin} from './plugin';
import {addPluginPromise} from '../utils/deep-linking';
import {notify} from '../utils/notifications';
import PCancelable from 'p-cancelable';
import {getFormatExtension} from '../common/constants';

interface ServiceContextOptions {
  plugin: InstalledPlugin;
}

class ServiceContext {
  requests: Array<GotPromise<any>> = [];
  config: Store;

  private readonly plugin: InstalledPlugin;

  constructor(options: ServiceContextOptions) {
    this.plugin = options.plugin;
    this.config = this.plugin.config;
  }

  request = (...args: Parameters<GotFn>) => {
    const request = got(...args);
    this.requests.push(request);
    return request;
  };

  copyToClipboard = (text: string) => {
    clipboard.writeText(text);
  };

  notify = (text: string, action?: () => any) => {
    return notify({
      body: text,
      title: this.plugin.isBuiltIn ? app.name : this.plugin.prettyName,
      click: action
    });
  };

  openConfigFile = () => {
    this.config.openInEditor();
  };

  waitForDeepLink = async () => {
    return new Promise(resolve => {
      addPluginPromise(this.plugin.name, resolve);
    });
  };
}

interface ShareServiceContextOptions extends ServiceContextOptions {
  onProgress: (text: string, percentage: number) => void;
  filePath: (options?: {fileType?: Format}) => Promise<string>;
  format: Format;
  prettyFormat: string;
  defaultFileName: string;
  onCancel: () => void;
}

export class ShareServiceContext extends ServiceContext {
  isCanceled = false;

  private readonly options: ShareServiceContextOptions;

  constructor(options: ShareServiceContextOptions) {
    super(options);
    this.options = options;
  }

  get format() {
    return this.options.format;
  }

  get prettyFormat() {
    return this.options.prettyFormat;
  }

  get defaultFileName() {
    return `${this.options.defaultFileName}.${getFormatExtension(this.options.format)}`;
  }

  filePath = async (options?: {fileType?: Format}) => {
    return this.options.filePath(options);
  };

  setProgress = (text: string, percentage: number) => {
    this.options.onProgress(text, percentage);
  };

  cancel = () => {
    this.isCanceled = true;
    this.options.onCancel();

    for (const request of this.requests) {
      request.cancel();
    }
  };
}

interface EditServiceContextOptions extends ServiceContextOptions {
  onProgress: (text: string, percentage: number) => void;
  inputPath: string;
  outputPath: string;
  exportOptions: {
    width: number;
    height: number;
    format: Format;
    fps: number;
    duration: number;
    isMuted: boolean;
    loop: boolean;
  };
  convert: (args: string[], text?: string) => PCancelable<void>;
  onCancel: () => void;
}

export class EditServiceContext extends ServiceContext {
  isCanceled = false;

  private readonly options: EditServiceContextOptions;

  constructor(options: EditServiceContextOptions) {
    super(options);
    this.options = options;
  }

  get inputPath() {
    return this.options.inputPath;
  }

  get outputPath() {
    return this.options.outputPath;
  }

  get exportOptions() {
    return this.options.exportOptions;
  }

  get convert() {
    return this.options.convert;
  }

  setProgress = (text: string, percentage: number) => {
    this.options.onProgress(text, percentage);
  };

  cancel = () => {
    this.isCanceled = true;
    this.options.onCancel();

    for (const request of this.requests) {
      request.cancel();
    }
  };
}

export type RecordServiceState<PersistedState extends Record<string, unknown> = Record<string, unknown>> = {
  persistedState?: PersistedState;
};

export interface RecordServiceContextOptions<State extends RecordServiceState> extends ServiceContextOptions {
  apertureOptions: ApertureOptions;
  state: State;
  setRecordingName: (name: string) => void;
}

export class RecordServiceContext<State extends RecordServiceState> extends ServiceContext {
  private readonly options: RecordServiceContextOptions<State>;

  constructor(options: RecordServiceContextOptions<State>) {
    super(options);
    this.options = options;
  }

  get state() {
    return this.options.state;
  }

  get apertureOptions() {
    return this.options.apertureOptions;
  }

  get setRecordingName() {
    return this.options.setRecordingName;
  }
}


================================================
FILE: main/plugins/service.ts
================================================

import PCancelable from 'p-cancelable';
import {Format} from '../common/types';
import {Schema} from '../utils/ajv';
import {EditServiceContext, RecordServiceContext, ShareServiceContext} from './service-context';

export interface Service<Config = any> {
  title: string;
  configDescription?: string;
  config?: {[P in keyof Config]: Schema};
}

export interface ShareService<Config = any> extends Service<Config> {
  formats: Format[];
  action: (context: ShareServiceContext) => PromiseLike<void> | PCancelable<void>;
}

export interface EditService<Config = any> extends Service<Config> {
  action: (context: EditServiceContext) => PromiseLike<void> | PCancelable<void>;
}

export type RecordServiceHook = 'willStartRecording' | 'didStartRecording' | 'didStopRecording';

export type RecordService<Config = any> = Service<Config> & {
  [key in RecordServiceHook]: ((context: RecordServiceContext<any>) => PromiseLike<void>) | undefined;
} & {
  willEnable?: () => PromiseLike<boolean>;
  cleanUp?: (persistedState: Record<string, unknown>) => void;
};


================================================
FILE: main/recording-history.ts
================================================
/* eslint-disable array-element-newline */
'use strict';

import {shell, clipboard} from 'electron';
import fs from 'fs';
import Store from 'electron-store';
import execa from 'execa';
import tempy from 'tempy';
import {SetOptional} from 'type-fest';

import {windowManager} from './windows/manager';
import {plugins} from './plugins';
import {generateTimestampedName} from './utils/timestamped-name';
import {Video} from './video';
import {ApertureOptions} from './common/types';
import Sentry, {isSentryEnabled} from './utils/sentry';

import ffmpegPath from './utils/ffmpeg-path';

export interface PastRecording {
  filePath: string;
  name: string;
  date: string;
}

export interface ActiveRecording extends PastRecording {
  apertureOptions: ApertureOptions;
  plugins: Record<string, Record<string, any>>;
}

export const recordingHistory = new Store<{
  activeRecording: ActiveRecording;
  recordings: PastRecording[];
}>({
  name: 'recording-history',
  schema: {
    activeRecording: {
      type: 'object',
      properties: {
        filePath: {
          type: 'string'
        },
        name: {
          type: 'string'
        },
        date: {
          type: 'string'
        },
        apertureOptions: {
          type: 'object'
        },
        plugins: {
          type: 'object'
        }
      }
    },
    recordings: {
      type: 'array',
      default: [],
      items: {
        type: 'object',
        properties: {
          filePath: {
            type: 'string'
          },
          name: {
            type: 'string'
          },
          date: {
            type: 'string'
          }
        }
      }
    }
  }
});

export const setCurrentRecording = ({
  filePath,
  name = generateTimestampedName(),
  date = new Date().toISOString(),
  apertureOptions,
  plugins = {}
}: SetOptional<ActiveRecording, 'name' | 'date'>) => {
  recordingHistory.set('activeRecording', {
    filePath,
    name,
    date,
    apertureOptions,
    plugins
  });
};

export const updatePluginState = (state: ActiveRecording['plugins']) => {
  recordingHistory.set('activeRecording.plugins', state);
};

export const stopCurrentRecording = (recordingName?: string) => {
  const {filePath, name} = recordingHistory.get('activeRecording');
  addRecording({
    filePath,
    name: recordingName ?? name,
    date: new Date().toISOString()
  });
  recordingHistory.delete('activeRecording');
};

export const getPastRecordings = (): PastRecording[] => {
  const recordings = recordingHistory.get('recordings', []);
  const validRecordings = recordings.filter(({filePath}) => fs.existsSync(filePath));
  recordingHistory.set('recordings', validRecordings);
  return validRecordings;
};

export const addRecording = (newRecording: PastRecording): PastRecording[] => {
  const recordings = [newRecording, ...recordingHistory.get('recordings', [])];
  const validRecordings = recordings.filter(({filePath}) => fs.existsSync(filePath));
  recordingHistory.set('recordings', validRecordings);
  return validRecordings;
};

export const cleanPastRecordings = () => {
  const recordings = getPastRecordings();
  for (const recording of recordings) {
    fs.unlinkSync(recording.filePath);
  }

  recordingHistory.set('recordings', []);
};

export const cleanUpRecordingPlugins = (usedPlugins: ActiveRecording['plugins']) => {
  const recordingPlugins = plugins.recordingPlugins;

  for (const pluginName of Object.keys(usedPlugins)) {
    const plugin = recordingPlugins.find(p => p.name === pluginName);
    for (const [serviceTitle, persistedState] of Object.entries(usedPlugins[pluginName])) {
      const service = plugin?.recordServices.find(s => s.title === serviceTitle);

      if (service?.cleanUp) {
        service.cleanUp(persistedState);
      }
    }
  }
};

export const handleIncompleteRecording = async (recording: ActiveRecording) => {
  cleanUpRecordingPlugins(recording.plugins);

  try {
    await execa(ffmpegPath, [
      '-i', recording.filePath,
      // Verbosity level
      '-v', 'error',
      // Force file type to null (we don't want to actually generate a file)
      // https://trac.ffmpeg.org/wiki/Null
      '-f', 'null', '-'
    ]);
  } catch (error) {
    return handleCorruptRecording(recording, (error as any).stderr);
  }

  return handleRecording(recording);
};

const handleRecording = async (recording: ActiveRecording) => {
  addRecording({
    filePath: recording.filePath,
    name: recording.name,
    date: recording.date
  });

  return windowManager.dialog?.open({
    title: 'Kap didn\'t shut down correctly.',
    detail: 'Looks like Kap crashed during a recording. Kap was able to locate the file and it appears to be playable.',
    buttons: [
      'Close',
      {
        label: 'Show in Finder',
        action: () => {
          shell.showItemInFolder(recording.filePath);
        }
      },
      {
        label: 'Show in Editor',
        action: async () => Video.getOrCreate({filePath: recording.filePath, title: recording.name}).openEditorWindow()
      }
    ]
  });
};

const knownErrors = [{
  test: (error: string) => error.includes('moov atom not found'),
  fix: async (filePath: string): Promise<string | void> => {
    try {
      const outputPath = tempy.file({extension: 'mp4'});

      await execa(ffmpegPath, [
        '-i',
        filePath,
        // Copy both streams
        '-vcodec',
        'copy',
        '-acodec',
        'copy',
        // Attempt to move the moov atom to the start of the file
        '-movflags',
        'faststart',
        outputPath
      ]);

      return outputPath;
    } catch {}
  }
}];

const handleCorruptRecording = async (recording: ActiveRecording, error: string) => {
  const options: any = {
    title: 'Kap didn\'t shut down correctly.',
    detail: `Looks like Kap crashed during a recording. We were able to locate the file. Unfortunately, it appears to be corrupt.\n\n${error}`,
    cancelId: 0,
    defaultId: 2,
    buttons: [
      'Close',
      {
        label: 'Copy Error',
        action: () => {
          clipboard.writeText(error);
        }
      },
      {
        label: 'Show in Finder',
        action: () => {
          shell.showItemInFolder(recording.filePath);
        }
      }
    ]
  };

  const applicableErrors = knownErrors.filter(({test}) => test(error));

  if (applicableErrors.length === 0) {
    if (isSentryEnabled) {
      // Collect info about possible unknown errors, to see if we can implement fixes using ffmpeg
      Sentry.captureException(new Error(`Corrupt recording: ${error}`));
    }

    return windowManager.dialog?.open(options);
  }

  options.message = 'We can attempt to repair the recording.';
  options.defaultId = 3;
  options.buttons.push({
    label: 'Attempt to Fix',
    activeLabel: 'Attempting to Fix…',
    action: async (_: any, updateUi: any) => {
      for (const {fix} of applicableErrors) {
        const outputPath = await fix(recording.filePath);

        if (outputPath) {
          addRecording({
            filePath: outputPath,
            name: recording.name,
            date: new Date().toISOString()
          });

          return updateUi({
            message: 'The recording was successfully repaired.',
            defaultId: 2,
            buttons: [
              'Close',
              {
                label: 'Show in Finder',
                action: () => {
                  shell.showItemInFolder(outputPath);
                }
              },
              {
                label: 'Show in Editor',
                action: async () => Video.getOrCreate({filePath: outputPath, title: recording.name}).openEditorWindow()
              }
            ]
          });
        }
      }

      return updateUi({
        message: 'Kap was unable to repair the recording.',
        defaultId: 2,
        buttons: [
          'Close',
          {
            label: 'Copy Error',
            action: () => {
              clipboard.writeText(error);
            }
          },
          {
            label: 'Show in Finder',
            action: () => {
              shell.showItemInFolder(recording.filePath);
            }
          }
        ]
      });
    }
  });

  return windowManager.dialog?.open(options);
};

export const hasActiveRecording = async () => {
  const activeRecording = recordingHistory.get('activeRecording');

  if (activeRecording) {
    await handleIncompleteRecording(activeRecording);
    recordingHistory.delete('activeRecording');
    return true;
  }

  return false;
};


================================================
FILE: main/remote-states/editor-options.ts
================================================
import Store from 'electron-store';
import {EditorOptionsRemoteState, ExportOptions, ExportOptionsPlugin, Format, RemoteStateHandler} from '../common/types';
import {formats} from '../common/constants';

import {plugins} from '../plugins';
import {apps} from '../plugins/built-in/open-with-plugin';
import {prettifyFormat} from '../utils/formats';

const exportUsageHistory = new Store<{[key in Format]: {lastUsed: number; plugins: Record<string, number>}}>({
  name: 'export-usage-history',
  defaults: {
    gif: {lastUsed: 6, plugins: {default: 1}},
    mp4: {lastUsed: 5, plugins: {default: 1}},
    webm: {lastUsed: 4, plugins: {default: 1}},
    hevc: {lastUsed: 3, plugins: {default: 1}},
    av1: {lastUsed: 2, plugins: {default: 1}},
    apng: {lastUsed: 1, plugins: {default: 1}}
  }
});

const fpsUsageHistory = new Store<{[key in Format]: number}>({
  name: 'fps-usage-history',
  schema: {
    apng: {
      type: 'number',
      minimum: 0,
      default: 60
    },
    webm: {
      type: 'number',
      minimum: 0,
      default: 60
    },
    mp4: {
      type: 'number',
      minimum: 0,
      default: 60
    },
    gif: {
      type: 'number',
      minimum: 0,
      default: 60
    },
    av1: {
      type: 'number',
      minimum: 0,
      default: 60
    },
    hevc: {
      type: 'number',
      minimum: 0,
      default: 60
    }
  }
});

const getEditOptions = () => {
  return plugins.editPlugins.flatMap(
    plugin => plugin.editServices
      .filter(service => plugin.config.validServices.includes(service.title))
      .map(service => ({
        title: service.title,
        pluginName: plugin.name,
        pluginPath: plugin.pluginPath,
        hasConfig: Object.keys(service.config ?? {}).length > 0
      }))
  );
};

const getExportOptions = () => {
  const installed = plugins.sharePlugins;

  const options = formats.map(format => ({
    format,
    prettyFormat: prettifyFormat(format),
    plugins: [] as ExportOptionsPlugin[],
    lastUsed: exportUsageHistory.get(format).lastUsed
  }));

  const sortFunc = <T extends {lastUsed: number}>(a: T, b: T) => b.lastUsed - a.lastUsed;

  for (const plugin of installed) {
    if (!plugin.isCompatible) {
      continue;
    }

    for (const service of plugin.shareServices) {
      for (const format of service.formats) {
        options.find(option => option.format === format)?.plugins.push({
          title: service.title,
          pluginName: plugin.name,
          pluginPath: plugin.pluginPath,
          apps: plugin.name === '_openWith' ? apps.get(format) : undefined,
          lastUsed: exportUsageHistory.get(format).plugins?.[plugin.name] ?? 0
        });
      }
    }
  }

  return options.map(option => ({...option, plugins: option.plugins.sort(sortFunc)})).sort(sortFunc);
};

const editorOptionsRemoteState: RemoteStateHandler<EditorOptionsRemoteState> = sendUpdate => {
  const state: ExportOptions = {
    formats: getExportOptions(),
    editServices: getEditOptions(),
    fpsHistory: fpsUsageHistory.store
  };

  const updatePlugins = () => {
    state.formats = getExportOptions();
    state.editServices = getEditOptions();
    sendUpdate(state);
  };

  plugins.on('installed', updatePlugins);
  plugins.on('uninstalled', updatePlugins);
  plugins.on('config-changed', updatePlugins);

  const actions = {
    updatePluginUsage: (_: string, {format, plugin}: {format: Format; plugin: string}) => {
      const usage = exportUsageHistory.get(format);
      const now = Date.now();

      usage.plugins[plugin] = now;
      usage.lastUsed = now;
      exportUsageHistory.set(format, usage);

      state.formats = getExportOptions();
      sendUpdate(state);
    },
    updateFpsUsage: (_: string, {format, fps}: {format: Format; fps: number}) => {
      fpsUsageHistory.set(format, fps);
      state.fpsHistory = fpsUsageHistory.store;
      sendUpdate(state);
    }
  };

  return {
    actions,
    getState: () => state
  };
};

export default editorOptionsRemoteState;
export const name = 'editor-options';


================================================
FILE: main/remote-states/exports-list.ts
================================================
import {ExportsListRemoteState, RemoteStateHandler} from '../common/types';
import Export from '../export';

const exportsListRemoteState: RemoteStateHandler<ExportsListRemoteState> = sendUpdate => {
  const getState = () => {
    return [...Export.exportsMap.keys()];
  };

  const subscribe = () => {
    const callback = () => {
      sendUpdate([...Export.exportsMap.keys()]);
    };

    Export.events.on('added', callback);
    return () => {
      Export.events.off('added', callback);
    };
  };

  return {
    subscribe,
    getState,
    actions: {}
  };
};

export default exportsListRemoteState;
export const name = 'exports-list';


================================================
FILE: main/remote-states/exports.ts
================================================
import {shell} from 'electron';
import {ExportsRemoteState, RemoteStateHandler} from '../common/types';
import Export from '../export';

const exportsRemoteState: RemoteStateHandler<ExportsRemoteState> = sendUpdate => {
  const getState = (exportId: string) => {
    const exportInstance = Export.fromId(exportId);

    if (!exportInstance) {
      return;
    }

    return exportInstance.data;
  };

  const subscribe = (exportId: string) => {
    const exportInstance = Export.fromId(exportId);

    if (!exportInstance) {
      return;
    }

    const callback = () => {
      sendUpdate(exportInstance.data, exportId);
    };

    exportInstance.on('updated', callback);
    return () => {
      exportInstance.off('updated', callback);
    };
  };

  const actions = {
    cancel: (exportId: string) => {
      Export.fromId(exportId)?.cancel();
    },
    copy: (exportId: string) => {
      Export.fromId(exportId)?.conversion?.copy();
    },
    retry: (exportId: string) => {
      Export.fromId(exportId)?.retry();
    },
    openInEditor: (exportId: string) => {
      Export.fromId(exportId)?.video?.openEditorWindow?.();
    },
    showInFolder: (exportId: string) => {
      const exportInstance = Export.fromId(exportId);

      if (!exportInstance) {
        return;
      }

      if (exportInstance.finalFilePath && !exportInstance.data.disableOutputActions) {
        shell.showItemInFolder(exportInstance.finalFilePath);
      }
    }
  } as any;

  return {
    subscribe,
    getState,
    actions
  };
};

export default exportsRemoteState;
export const name = 'exports';


================================================
FILE: main/remote-states/index.ts
================================================
import setupRemoteState from './setup-remote-state';

const remoteStateNames = ['editor-options', 'exports', 'exports-list'];

export const setupRemoteStates = async () => {
  return Promise.all(remoteStateNames.map(async fileName => {
    const state = require(`./${fileName}`);
    console.log(`Setting up remote-state: ${state.name}`);
    setupRemoteState(state.name, state.default);
  }));
};


================================================
FILE: main/remote-states/setup-remote-state.ts
================================================
import {RemoteState, getChannelNames} from './utils';
import {ipcMain} from 'electron-better-ipc';
import {BrowserWindow} from 'electron';

// eslint-disable-next-line @typescript-eslint/ban-types
const setupRemoteState = async <State, Actions extends Record<string, Function>>(name: string, callback: RemoteState<State, Actions>) => {
  const channelNames = getChannelNames(name);

  const renderersMap = new Map<string, Set<BrowserWindow>>();

  const sendUpdate = async (state?: State, id?: string) => {
    if (id) {
      const renderers = renderersMap.get(id) ?? new Set();

      for (const renderer of renderers) {
        ipcMain.callRenderer(renderer, channelNames.stateUpdated, {state, id});
      }

      return;
    }

    for (const [windowId, renderers] of renderersMap.entries()) {
      for (const renderer of renderers) {
        if (renderer && !renderer.isDestroyed()) {
          ipcMain.callRenderer(renderer, channelNames.stateUpdated, {state: state ?? (await getState?.(windowId))});
        } else {
          renderers.delete(renderer);
        }
      }
    }
  };

  const {getState, actions = {}, subscribe} = await callback(sendUpdate);

  ipcMain.answerRenderer(channelNames.subscribe, (customId: string, window: BrowserWindow) => {
    const id = customId ?? window.id.toString();

    if (!renderersMap.has(id)) {
      renderersMap.set(id, new Set());
    }

    renderersMap.get(id)?.add(window);
    const unsubscribe = subscribe?.(id);

    window.on('close', () => {
      renderersMap.get(id)?.delete(window);
      unsubscribe?.();
    });

    return Object.keys(actions);
  });

  ipcMain.answerRenderer(channelNames.getState, async (customId: string, window: BrowserWindow) => {
    const id = customId ?? window.id.toString();
    return getState(id);
  });

  ipcMain.answerRenderer(channelNames.callAction, ({key, data, id: customId}: any, window: BrowserWindow) => {
    const id = customId || window.id.toString();
    return (actions as any)[key]?.(id, ...data);
  });
};

export default setupRemoteState;


================================================
FILE: main/remote-states/utils.ts
================================================
import {Promisable} from 'type-fest';

export const getChannelName = (name: string, action: string) => `kap-remote-state-${name}-${action}`;

export const getChannelNames = (name: string) => ({
  subscribe: getChannelName(name, 'subscribe'),
  getState: getChannelName(name, 'get-state'),
  callAction: getChannelName(name, 'call-action'),
  stateUpdated: getChannelName(name, 'state-updated')
});

// eslint-disable-next-line @typescript-eslint/ban-types
export type RemoteState<State, Actions extends Record<string, Function>> = (sendUpdate: (state?: State, id?: string) => void) => Promisable<{
  getState: (id?: string) => Promisable<State>;
  actions: Actions;
  subscribe?: (id?: string) => undefined | (() => void);
}>;


================================================
FILE: main/tray.ts
================================================
'use strict';

import {Tray} from 'electron';
import {KeyboardEvent} from 'electron/main';
import path from 'path';
import {getCogMenu} from './menus/cog';
import {getRecordMenu} from './menus/record';
import {track} from './common/analytics';
import {openFiles} from './utils/open-files';
import {windowManager} from './windows/manager';
import {pauseRecording, resumeRecording, stopRecording} from './aperture';

let tray: Tray;
let trayAnimation: NodeJS.Timeout | undefined;

const openContextMenu = async () => {
  tray.popUpContextMenu(await getCogMenu());
};

const openRecordingContextMenu = async () => {
  tray.popUpContextMenu(await getRecordMenu(false));
};

const openPausedContextMenu = async () => {
  tray.popUpContextMenu(await getRecordMenu(true));
};

const openCropperWindow = () => windowManager.cropper?.open();

export const initializeTray = () => {
  tray = new Tray(path.join(__dirname, '..', 'static', 'menubarDefaultTemplate.png'));
  tray.on('click', openCropperWindow);
  tray.on('right-click', openContextMenu);
  tray.on('drop-files', (_, files) => {
    track('editor/opened/tray');
    openFiles(...files);
  });

  return tray;
};

export const disableTray = () => {
  tray.removeListener('click', openCropperWindow);
  tray.removeListener('right-click', openContextMenu);
};

export const resetTray = () => {
  if (trayAnimation) {
    clearTimeout(trayAnimation);
  }

  tray.removeAllListeners('click');
  tray.removeAllListeners('right-click');

  tray.setImage(path.join(__dirname, '..', 'static', 'menubarDefaultTemplate.png'));
  tray.on('click', openCropperWindow);
  tray.on('right-click', openContextMenu);
};

export const setRecordingTray = () => {
  animateIcon();

  tray.removeAllListeners('right-click');

  // TODO: figure out why this is marked as missing. It's defined properly in the electron.d.ts file
  tray.once('click', onRecordingTrayClick);
  tray.on('right-click', openRecordingContextMenu);
};

export const setPausedTray = () => {
  if (trayAnimation) {
    clearTimeout(trayAnimation);
  }

  tray.removeAllListeners('right-click');

  tray.setImage(path.join(__dirname, '..', 'static', 'pauseTemplate.png'));
  tray.once('click', resumeRecording);
  tray.on('right-click', openPausedContextMenu);
};

const onRecordingTrayClick = (event: KeyboardEvent) => {
  if (event.altKey) {
    pauseRecording();
    return;
  }

  stopRecording();
};

const animateIcon = async () => new Promise<void>(resolve => {
  const interval = 20;
  let i = 0;

  const next = () => {
    trayAnimation = setTimeout(() => {
      const number = String(i++).padStart(5, '0');
      const filename = `loading_${number}Template.png`;

      try {
        tray.setImage(path.join(__dirname, '..', 'static', 'menubar-loading', filename));
        next();
      } catch {
        trayAnimation = undefined;
        resolve();
      }
    }, interval);
  };

  next();
});


================================================
FILE: main/utils/ajv.ts
================================================
import Ajv, {Options} from 'ajv';
import {Schema as JSONSchema} from 'electron-store';
import {Except} from 'type-fest';

export type Schema<T extends {'required': boolean} = any> = Except<JSONSchema<T>, 'required'> & {
  required?: boolean;
  customType?: string;
};

const hexColorValidator = () => {
  return {
    type: 'string',
    pattern: /^((0x)|#)([\dA-Fa-f]{8}|[\dA-Fa-f]{6})$/.source
  };
};

const keyboardShortcutValidator = () => {
  return {
    type: 'string'
  };
};

// eslint-disable-next-line @typescript-eslint/ban-types
const validators = new Map<string, (parentSchema: object) => object>([
  ['hexColor', hexColorValidator],
  ['keyboardShortcut', keyboardShortcutValidator]
]);

export default class CustomAjv extends Ajv {
  constructor(options: Options) {
    super(options);

    this.addKeyword('customType', {
      // eslint-disable-next-line @typescript-eslint/ban-types
      macro: (schema: string, parentSchema: object) => {
        const validator = validators.get(schema);

        if (!validator) {
          throw new Error(`No custom type found for ${schema}`);
        }

        return validator(parentSchema);
      },
      metaSchema: {
        type: 'string',
        enum: [...validators.keys()]
      }
    });
  }
}


================================================
FILE: main/utils/deep-linking.ts
================================================
import {windowManager} from '../windows/manager';

const pluginPromises = new Map<string, (path: string) => void>();

const handlePluginsDeepLink = (path: string) => {
  const [plugin, ...rest] = path.split('/');

  if (pluginPromises.has(plugin)) {
    pluginPromises.get(plugin)?.(rest.join('/'));
    pluginPromises.delete(plugin);
    return;
  }

  console.error(`Received link for plugin “${plugin}” but there was no registered listener.`);
};

export const addPluginPromise = (plugin: string, resolveFunction: (path: string) => void) => {
  pluginPromises.set(plugin, resolveFunction);
};

const triggerPluginAction = (action: string) => (name: string) => windowManager.preferences?.open({target: {name, action}});

const routes = new Map([
  ['plugins', handlePluginsDeepLink],
  ['install-plugin', triggerPluginAction('install')],
  ['configure-plugin', triggerPluginAction('configure')]
]);

export const handleDeepLink = (url: string) => {
  const {host, pathname} = new URL(url);

  if (routes.has(host)) {
    return routes.get(host)?.(pathname.slice(1));
  }

  console.error(`Route not recognized: ${host} (${url}).`);
};


================================================
FILE: main/utils/devices.ts
================================================
import {hasMicrophoneAccess} from '../common/system-permissions';
import * as audioDevices from 'macos-audio-devices';
import {settings} from '../common/settings';
import {defaultInputDeviceId} from '../common/constants';
import Sentry from './sentry';
const aperture = require('aperture');

const {showError} = require('./errors');

export const getAudioDevices = async () => {
  if (!hasMicrophoneAccess()) {
    return [];
  }

  try {
    const devices = await audioDevices.getInputDevices();

    return devices.sort((a, b) => {
      if (a.transportType === b.transportType) {
        return a.name.localeCompare(b.name);
      }

      if (a.transportType === 'builtin') {
        return -1;
      }

      if (b.transportType === 'builtin') {
        return 1;
      }

      return 0;
    }).map(device => ({id: device.uid, name: device.name}));
  } catch (error) {
    try {
      const devices = await aperture.audioDevices();

      if (!Array.isArray(devices)) {
        Sentry.captureException(new Error(`devices is not an array: ${JSON.stringify(devices)}`));
        showError(error);
        return [];
      }

      return devices;
    } catch (error) {
      showError(error);
      return [];
    }
  }
};

export const getDefaultInputDevice = () => {
  try {
    const device = audioDevices.getDefaultInputDevice.sync();
    return {
      id: device.uid,
      name: device.name
    };
  } catch {
    // Running on 10.13 and don't have swift support libs. No need to report
    return undefined;
  }
};

export const getSelectedInputDeviceId = () => {
  const audioInputDeviceId = settings.get('audioInputDeviceId', defaultInputDeviceId);

  if (audioInputDeviceId === defaultInputDeviceId) {
    const device = getDefaultInputDevice();
    return device?.id;
  }

  return audioInputDeviceId;
};

export const initializeDevices = async () => {
  const audioInputDeviceId = settings.get('audioInputDeviceId');

  if (hasMicrophoneAccess()) {
    const devices = await getAudioDevices();

    if (!devices.some((device: any) => device.id === audioInputDeviceId)) {
      settings.set('audioInputDeviceId', defaultInputDeviceId);
    }
  }
};


================================================
FILE: main/utils/dock.ts
================================================
import {app} from 'electron';
import {Promisable} from 'type-fest';

export const ensureDockIsShowing = async (action: () => Promisable<void>) => {
  const wasDockShowing = app.dock.isVisible();
  if (!wasDockShowing) {
    await app.dock.show();
  }

  await action();

  if (!wasDockShowing) {
    app.dock.hide();
  }
};

export const ensureDockIsShowingSync = (action: () => void) => {
  const wasDockShowing = app.dock.isVisible();
  if (!wasDockShowing) {
    app.dock.show();
  }

  action();

  if (!wasDockShowing) {
    app.dock.hide();
  }
};


================================================
FILE: main/utils/encoding.ts
================================================
/* eslint-disable array-element-newline */

import path from 'path';
import execa from 'execa';
import tempy from 'tempy';
import {track} from '../common/analytics';
import ffmpegPath from './ffmpeg-path';

export const getEncoding = async (filePath: string) => {
  try {
    await execa(ffmpegPath, ['-i', filePath]);
    return undefined;
  } catch (error) {
    return /.*: Video: (.*?) \(.*/.exec((error as any)?.stderr)?.[1];
  }
};

// `ffmpeg -i original.mp4 -vcodec libx264 -crf 27 -preset veryfast -c:a copy output.mp4`
export const convertToH264 = async (inputPath: string) => {
  const outputPath = tempy.file({extension: path.extname(inputPath)});

  track('encoding/converted/hevc');

  await execa(ffmpegPath, [
    '-i', inputPath,
    '-vcodec', 'libx264',
    '-crf', '27',
    '-preset', 'veryfast',
    '-c:a', 'copy',
    outputPath
  ]);

  return outputPath;
};


================================================
FILE: main/utils/errors.ts
================================================
import path from 'path';
import {clipboard, shell, app} from 'electron';
import ensureError from 'ensure-error';
import cleanStack from 'clean-stack';
import isOnline from 'is-online';
import {openNewGitHubIssue} from 'electron-util';
import got from 'got';
import delay from 'delay';
import macosRelease from './macos-release';

import {windowManager} from '../windows/manager';
import Sentry, {isSentryEnabled} from './sentry';
import {InstalledPlugin} from '../plugins/plugin';

const MAX_RETRIES = 10;

const ERRORS_TO_IGNORE = [
  /net::ERR_CONNECTION_TIMED_OUT/,
  /net::ERR_NETWORK_IO_SUSPENDED/,
  /net::ERR_CONNECTION_CLOSED/
];

const shouldIgnoreError = (errorText: string) => ERRORS_TO_IGNORE.some(regex => regex.test(errorText));

type SentryIssue = {
  issueId: string;
  shortId: string;
  permalink: string;
  ghUrl: string;
} | {
  issueId: string;
  shortId: string;
  permalink: string;
  ghIssueTemplate: string;
} | {
  error: string;
};

const getSentryIssue = async (eventId: string, tries = 0): Promise<SentryIssue | undefined | void> => {
  if (tries > MAX_RETRIES) {
    return;
  }

  try {
    // This endpoint will poll the Sentry API with the event ID until it gets an issue ID (~8 seconds).
    // It will then filter through GitHub issues to try and find an issue matching that issue ID.
    // It will return the issue information if it finds it or a partial template to use to create one if not.
    // https://github.com/wulkano/kap-sentry-tracker
    const {body} = await got.get(`https://kap-sentry-tracker.vercel.app/api/event/${eventId}`, {json: true});

    if (body.pending) {
      await delay(2000);
      return await getSentryIssue(eventId, tries + 1);
    }

    return body;
  } catch (error) {
    // We are not using `showError` again here to avoid an infinite error cycle
    console.log(error);
  }
};

const getPrettyStack = (error: Error) => {
  const pluginsPath = path.join(app.getPath('userData'), 'plugins', 'node_modules');
  return cleanStack(error.stack ?? '', {pretty: true, basePath: pluginsPath});
};

const release = macosRelease();

const getIssueBody = (title: string, errorStack: string, sentryTemplate = '') => `
${sentryTemplate}<!--
Thank you for helping us test Kap. Your feedback helps us make Kap better for everyone!
-->

**macOS version:**    ${release.name} (${release.version})
**Kap version:**      ${app.getVersion()}

\`\`\`
${title}

${errorStack}
\`\`\`

<!-- If you have additional information, enter it below. -->
`;

export const showError = async (
  error: Error,
  {
    title: customTitle,
    plugin
  }: {
    title?: string;
    plugin?: InstalledPlugin;
  } = {}
) => {
  await app.whenReady();
  const ensuredError = ensureError(error);
  const title = customTitle ?? ensuredError.name;
  const detail = getPrettyStack(ensuredError);

  console.log(error);
  if (shouldIgnoreError(`${title}\n${detail}`)) {
    return;
  }

  const mainButtons = [
    'Don\'t Report',
    {
      label: 'Copy Error',
      action: () => {
        clipboard.writeText(`${title}\n${detail}`);
      }
    }
  ];

  // If it's a plugin error, offer to open an issue on the plugin repo (if known)
  if (plugin) {
    const openIssueButton = plugin.repoUrl && {
      label: 'Open Issue',
      action: () => {
        openNewGitHubIssue({
          repoUrl: plugin.repoUrl,
          title,
          body: getIssueBody(title, detail)
        });
      }
    };

    return windowManager.dialog?.open({
      title,
      detail,
      cancelId: 0,
      defaultId: openIssueButton ? 2 : 0,
      buttons: [...mainButtons, openIssueButton].filter(Boolean)
    });
  }

  let message;
  const buttons: any[] = [...mainButtons];

  if (isSentryEnabled && await isOnline()) {
    const eventId = Sentry.captureException(ensuredError);
    const sentryIssuePromise = getSentryIssue(eventId);

    message = 'Reporting this issue will help us track it better and resolve it faster.';

    buttons.push({
      label: 'Collect Info and Report',
      activeLabel: 'Collecting Info…',
      action: async (_: unknown, updateUi: any) => {
        const issue = await sentryIssuePromise;

        if (!issue || 'error' in issue) {
          updateUi({
            message: 'Something went wrong while collecting the information.',
            buttons: mainButtons
          });
        } else if ('ghUrl' in issue) {
          updateUi({
            message: 'This issue is already being tracked!',
            buttons: [
              ...mainButtons, {
                label: 'View Issue',
                action: async () => shell.openExternal(issue.ghUrl)
              }
            ]
          });
        } else {
          updateUi({
            buttons: [
              ...mainButtons,
              {
                label: 'Open Issue',
                action: () => {
                  openNewGitHubIssue({
                    user: 'wulkano',
                    repo: 'kap',
                    title,
                    body: getIssueBody(title, detail, issue.ghIssueTemplate),
                    labels: ['sentry']
                  });
                }
              }
            ]
          });
        }
      }
    });
  }

  return windowManager.dialog?.open({
    title,
    detail,
    buttons,
    message,
    cancelId: 0,
    defaultId: buttons.length > 2 ? 2 : 0
  });
};

export const setupErrorHandling = () => {
  process.on('uncaughtException', error => {
    showError(error, {title: 'Unhandled Error'});
  });

  process.on('unhandledRejection', error => {
    showError(ensureError(error), {title: 'Unhandled Promise Rejection'});
  });
};


================================================
FILE: main/utils/ffmpeg-path.ts
================================================
import ffmpeg from 'ffmpeg-static';
import util from 'electron-util';

const ffmpegPath = util.fixPathForAsarUnpack(ffmpeg);

export default ffmpegPath;


================================================
FILE: main/utils/format-time.ts
================================================
import moment from 'moment';

const formatTime = (time: number, options: any) => {
  options = {
    showMilliseconds: false,
    ...options
  };

  const durationFormatted = options.extra ?
    `  (${format(options.extra, options)})` :
    '';

  return `${format(time, options)}${durationFormatted}`;
};

const format = (time: number, {showMilliseconds} = {showMilliseconds: false}) => {
  const formatString = `${time >= 60 * 60 ? 'hh:m' : ''}m:ss${showMilliseconds ? '.SS' : ''}`;

  return moment().startOf('day').millisecond(time * 1000).format(formatString);
};

export default formatTime;


================================================
FILE: main/utils/formats.ts
================================================
import {Format} from '../common/types';

const formats = new Map([
  [Format.gif, 'GIF'],
  [Format.hevc, 'MP4 (H265)'],
  [Format.mp4, 'MP4 (H264)'],
  [Format.av1, 'MP4 (AV1)'],
  [Format.webm, 'WebM'],
  [Format.apng, 'APNG']
]);

export const prettifyFormat = (format: Format): string => {
  return formats.get(format)!;
};


================================================
FILE: main/utils/fps.ts
================================================
import execa from 'execa';
import ffmpegPath from './ffmpeg-path';

const getFps = async (filePath: string) => {
  try {
    await execa(ffmpegPath, ['-i', filePath]);
    return undefined;
  } catch (error) {
    return /.*, (.*) fp.*/.exec((error as any)?.stderr)?.[1];
  }
};

export default getFps;


================================================
FILE: main/utils/image-preview.ts
================================================
/* eslint-disable array-element-newline */

import {BrowserWindow, dialog} from 'electron';
import execa from 'execa';
import tempy from 'tempy';
import {promisify} from 'util';
import type {Video} from '../video';
import {generateTimestampedName} from './timestamped-name';
import ffmpegPath from './ffmpeg-path';

const base64Img = require('base64-img');

const getBase64 = promisify(base64Img.base64);

export const generatePreviewImage = async (filePath: string): Promise<{path: string; data: string} | undefined> => {
  const previewPath = tempy.file({extension: '.jpg'});

  try {
    await execa(ffmpegPath, [
      '-ss', '0',
      '-i', filePath,
      '-t', '1',
      '-vframes', '1',
      '-f', 'image2',
      previewPath
    ]);
  } catch {
    return;
  }

  try {
    return {
      path: previewPath,
      data: await getBase64(previewPath)
    };
  } catch {
    return {
      path: previewPath,
      data: ''
    };
  }
};

export const saveSnapshot = async (video: Video, time: number) => {
  const {filePath: outputPath} = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow()!, {
    defaultPath: generateTimestampedName('Snapshot', '.jpg')
  });

  if (outputPath) {
    await execa(ffmpegPath, [
      '-i', video.filePath,
      '-ss', time.toString(),
      '-vframes', '1',
      outputPath
    ]);
  }
};


================================================
FILE: main/utils/macos-release.ts
================================================
// Vendored: https://github.com/sindresorhus/macos-release

'use strict';
const os = require('os');

const nameMap = {
  22: ['Ventura', '13'],
  21: ['Monterey', '12'],
  20: ['Big Sur', '11'],
  19: ['Catalina', '10.15'],
  18: ['Mojave', '10.14'],
  17: ['High Sierra', '10.13'],
  16: ['Sierra', '10.12'],
  15: ['El Capitan', '10.11'],
  14: ['Yosemite', '10.10'],
  13: ['Mavericks', '10.9'],
  12: ['Mountain Lion', '10.8'],
  11: ['Lion', '10.7'],
  10: ['Snow Leopard', '10.6'],
  9: ['Leopard', '10.5'],
  8: ['Tiger', '10.4'],
  7: ['Panther', '10.3'],
  6: ['Jaguar', '10.2'],
  5: ['Puma', '10.1']
} as const;

export default function macosRelease(release?: string) {
  const releaseCleaned = (release ?? os.release()).split('.')[0] as keyof typeof nameMap;
  const [name, version] = nameMap[releaseCleaned] ?? ['Unknown', ''];

  return {
    name,
    version
  };
}


================================================
FILE: main/utils/notifications.ts
================================================
import {Notification, NotificationConstructorOptions, NotificationAction, app} from 'electron';

// Need to persist the notifications, otherwise it is garbage collected and the actions don't trigger
// https://github.com/electron/electron/issues/12690
const notifications = new Set<Notification>();

interface Action extends NotificationAction {
  action?: () => void | Promise<void>;
}

interface NotificationOptions extends NotificationConstructorOptions {
  actions?: Action[];
  click?: () => void | Promise<void>;
  show?: boolean;
}

type NotificationPromise = Promise<void> & {
  show: () => void;
  close: () => void;
};

export const notify = (options: NotificationOptions): NotificationPromise => {
  const notification = new Notification(options);

  notifications.add(notification);

  const promise = new Promise(resolve => {
    if (options.click && typeof options.click === 'function') {
      notification.on('click', () => {
        resolve(options.click?.());
      });
    }

    if (options.actions && options.actions.length > 0) {
      notification.on('action', (_, index) => {
        const button = options.actions?.[index];

        if (button?.action && typeof button?.action === 'function') {
          resolve(button?.action?.());
        } else {
          resolve(index);
        }
      });
    }

    notification.on('close', () => {
      resolve(undefined);
    });
  });

  promise.then(() => {
    notifications.delete(notification);
  });

  (promise as NotificationPromise).show = () => {
    notification.show();
  };

  (promise as NotificationPromise).close = () => {
    notification.close();
  };

  if (options.show ?? true) {
    notification.show();
  }

  return promise as NotificationPromise;
};

notify.simple = (text: string) => notify({title: app.name, body: text});


================================================
FILE: main/utils/open-files.ts
================================================
'use strict';
import path from 'path';
import {supportedVideoExtensions} from '../common/constants';
import {Video} from '../video';

const fileExtensions = supportedVideoExtensions.map(ext => `.${ext}`);

export const openFiles = async (...filePaths: string[]) => {
  return Promise.all(
    filePaths
      .filter(filePath => fileExtensions.includes(path.extname(filePath).toLowerCase()))
      .map(async filePath => {
        return Video.getOrCreate({
          filePath
        }).openEditorWindow();
      })
  );
};



================================================
FILE: main/utils/protocol.ts
================================================
import {protocol} from 'electron';

export const setupProtocol = () => {
  // Fix protocol issue in order to support loading editor previews
  // https://github.com/electron/electron/issues/23757#issuecomment-640146333
  protocol.registerFileProtocol('file', (request, callback) => {
    const pathname = decodeURI(request.url.replace('file:///', ''));
    callback(pathname);
  });
};


================================================
FILE: main/utils/routes.ts
================================================
import {app, BrowserWindow} from 'electron';
import {is} from 'electron-util';

export const loadRoute = (window: BrowserWindow, routeName: string, {openDevTools}: {openDevTools?: boolean} = {}) => {
  if (is.development) {
    window.loadURL(`http://localhost:8000/${routeName}`);
    window.webContents.openDevTools({mode: 'detach'});
  } else {
    window.loadFile(`${app.getAppPath()}/renderer/out/${routeName}.html`);
    if (openDevTools) {
      window.webContents.openDevTools({mode: 'detach'});
    }
  }
};


================================================
FILE: main/utils/sentry.ts
================================================
'use strict';

import {app} from 'electron';
import {is} from 'electron-util';
import * as Sentry from '@sentry/electron';
import {settings} from '../common/settings';

const SENTRY_PUBLIC_DSN = 'https://2dffdbd619f34418817f4db3309299ce@sentry.io/255536';

export const isSentryEnabled = !is.development && settings.get('allowAnalytics');

if (isSentryEnabled) {
  const release = `${app.name}@${app.getVersion()}`.toLowerCase();
  Sentry.init({
    dsn: SENTRY_PUBLIC_DSN,
    release
  });
}

export default Sentry;


================================================
FILE: main/utils/shortcut-to-accelerator.ts
================================================

export const shortcutToAccelerator = (shortcut: any) => {
  const {metaKey, altKey, ctrlKey, shiftKey, character} = shortcut;
  if (!character) {
    throw new Error(`shortcut needs character ${JSON.stringify(shortcut)}`);
  }

  const keys = [
    metaKey && 'Command',
    altKey && 'Option',
    ctrlKey && 'Control',
    shiftKey && 'Shift',
    character
  ].filter(Boolean);
  return keys.join('+');
};


================================================
FILE: main/utils/timestamped-name.ts
================================================
import moment from 'moment';

export const generateTimestampedName = (title = 'Kapture', extension = '') => `${title} ${moment().format('YYYY-MM-DD')} at ${moment().format('HH.mm.ss')}${extension}`;


================================================
FILE: main/utils/track-duration.ts
================================================
// TODO: Add interface to aperture-node for getting recording duration instead of using this https://github.com/wulkano/aperture-node/issues/29
let overallDuration = 0;
let currentDurationStart = 0;

export const getOverallDuration = (): number => overallDuration;

export const getCurrentDurationStart = (): number => currentDurationStart;

export const setOverallDuration = (duration: number): void => {
  overallDuration = duration;
};

export const setCurrentDurationStart = (duration: number): void => {
  currentDurationStart = duration;
};


================================================
FILE: main/utils/windows.ts
================================================
import {Menu, MenuItem, nativeImage} from 'electron';
import Store from 'electron-store';
import {windowManager} from '../windows/manager';

const {getWindows, activateWindow} = require('mac-windows');
const {getAppIconListByPid} = require('node-mac-app-icon');

export interface MacWindow {
  pid: number;
  ownerName: string;
  name: string;
  width: number;
  height: number;
  x: number;
  y: number;
  number: number;
}

const APP_BLACKLIST = [
  'Kap',
  'Kap Beta'
];

const store = new Store<{
  appUsageHistory: Record<number, {
    count: number;
    lastUsed: number;
  } | undefined>;
}>({
  name: 'usage-history'
});

const usageHistory = store.get('appUsageHistory', {});

const isValidApp = ({ownerName}: MacWindow) => !APP_BLACKLIST.includes(ownerName);

const getWindowList = async () => {
  const windows = await getWindows() as MacWindow[];
  const images = await getAppIconListByPid(windows.map(win => win.pid), {
    size: 16,
    failOnError: false
  }) as Array<{
    pid: number;
    icon: Buffer;
  }>;

  let maxLastUsed = 0;

  return windows.filter(window => isValidApp(window)).map(win => {
    const iconImage = images.find(img => img.pid === win.pid);
    const icon = iconImage?.icon ? nativeImage.createFromBuffer(iconImage.icon) : undefined;

    const window = {
      ...win,
      icon2x: icon,
      icon: icon?.resize({width: 16, height: 16}),
      count: 0,
      lastUsed: 0,
      ...usageHistory[win.pid]
    };

    maxLastUsed = Math.max(maxLastUsed, window.lastUsed);
    return window;
  }).sort((a, b) => {
    if (a.lastUsed === maxLastUsed) {
      return -1;
    }

    if (b.lastUsed === maxLastUsed) {
      return 1;
    }

    return b.count - a.count;
  });
};

export const buildWindowsMenu = async (selected: string) => {
  const menu = new Menu();
  const windows = await getWindowList();

  for (const win of windows) {
    menu.append(
      new MenuItem({
        label: win.ownerName,
        icon: win.icon,
        type: 'checkbox',
        checked: win.ownerName === selected,
        click: () => {
          activateApp(win);
        }
      })
    );
  }

  return menu;
};

const updateAppUsageHistory = (app: MacWindow) => {
  const {count = 0} = usageHistory[app.pid] ?? {};

  usageHistory[app.pid] = {
    count: count + 1,
    lastUsed: Date.now()
  };

  store.set('appUsageHistory', usageHistory);
};

export const activateApp = (window: MacWindow) => {
  updateAppUsageHistory(window);
  windowManager.cropper?.selectApp(window, activateWindow);
};


================================================
FILE: main/video.ts
================================================
import path from 'path';
import getFps from './utils/fps';
import {getEncoding, convertToH264} from './utils/encoding';
import {nativeImage, NativeImage, screen} from 'electron';
import {ApertureOptions, Encoding} from './common/types';
import {generateTimestampedName} from './utils/timestamped-name';
import fs from 'fs';
import {generatePreviewImage} from './utils/image-preview';
import {windowManager} from './windows/manager';

interface VideoOptions {
  filePath: string;
  title?: string;
  fps?: number;
  encoding?: Encoding;
  previewPath?: string;
  pixelDensity?: number;
  isNewRecording?: boolean;
}

export class Video {
  static all = new Map<string, Video>();

  filePath: string;
  title: string;
  fps?: number;
  encoding?: Encoding;
  pixelDensity: number;
  previewPath?: string;
  dragIcon?: NativeImage;
  isNewRecording = false;
  isReady = false;
  previewImage?: {path: string; data: string};

  private readonly readyPromise: Promise<void>;
  private readonly previewReadyPromise: Promise<string | undefined>;

  constructor(options: VideoOptions) {
    this.filePath = options.filePath;
    this.title = options.title ?? path.parse(this.filePath).name;
    this.fps = options.fps;
    this.encoding = options.encoding;
    this.pixelDensity = options.pixelDensity ?? 1;
    this.isNewRecording = options.isNewRecording ?? false;
    this.previewPath = options.previewPath;

    Video.all.set(this.filePath, this);

    this.readyPromise = this.collectInfo();
    this.previewReadyPromise = this.readyPromise.then(async () => this.getPreviewPath());
  }

  static fromId(id: string) {
    return this.all.get(id);
  }

  static getOrCreate(options: VideoOptions) {
    return Video.fromId(options.filePath) ?? new Video(options);
  }

  async getFps() {
    if (!this.fps) {
      this.fps = Math.round(Number.parseFloat((await getFps(this.filePath)) ?? '0'));
    }

    return this.fps;
  }

  async exists() {
    try {
      await fs.promises.access(this.filePath, fs.constants.F_OK);
      return true;
    } catch {
      return false;
    }
  }

  async getEncoding() {
    if (!this.encoding) {
      this.encoding = (await getEncoding(this.filePath)) as Encoding;
    }

    return this.encoding;
  }

  async getPreviewPath() {
    if (!await this.exists()) {
      return;
    }

    if (!this.previewPath) {
      if (this.encoding === 'h264') {
        this.previewPath = this.filePath;
      } else {
        this.previewPath = await convertToH264(this.filePath);
      }
    }

    return this.previewPath;
  }

  async getDragIcon({width, height}: {width: number; height: number}) {
    const previewImagePath = (await this.generatePreviewImage())?.path;

    if (previewImagePath) {
      const resizeOptions = width > height ? {width: 64} : {height: 64};
      return nativeImage.createFromPath(previewImagePath).resize(resizeOptions);
    }

    return nativeImage.createEmpty();
  }

  async generatePreviewImage() {
    if (!this.previewImage) {
      this.previewImage = await generatePreviewImage(this.filePath);
    }

    return this.previewImage;
  }

  async whenReady() {
    return this.readyPromise;
  }

  async whenPreviewReady() {
    return this.previewReadyPromise;
  }

  async openEditorWindow() {
    return windowManager.editor?.open(this);
  }

  private async collectInfo() {
    if (!await this.exists()) {
      return;
    }

    await Promise.all([
      this.getFps(),
      this.getEncoding()
    ]);

    this.isReady = true;
  }
}

export class Recording extends Video {
  apertureOptions: ApertureOptions;

  constructor(options: VideoOptions & {apertureOptions: ApertureOptions}) {
    const displays = screen.getAllDisplays();
    const pixelDensity = displays.find(display => display.id === options.apertureOptions.screenId)?.scaleFactor;

    super({
      filePath: options.filePath,
      title: options.title ?? generateTimestampedName(),
      fps: options.apertureOptions.fps,
      encoding: options.apertureOptions.videoCodec ?? Encoding.h264,
      pixelDensity
    });

    this.apertureOptions = options.apertureOptions;
    this.isNewRecording = true;
  }
}


================================================
FILE: main/windows/config.ts
================================================
'use strict';

import {BrowserWindow} from 'electron';
import {ipcMain as ipc} from 'electron-better-ipc';
import pEvent from 'p-event';

import {loadRoute} from '../utils/routes';
import {windowManager} from './manager';

const openConfigWindow = async (pluginName: string) => {
  const prefsWindow = await windowManager.preferences?.open();
  const configWindow = new BrowserWindow({
    width: 320,
    height: 436,
    resizable: false,
    movable: false,
    minimizable: false,
    maximizable: false,
    fullscreenable: false,
    titleBarStyle: 'hiddenInset',
    show: false,
    parent: prefsWindow,
    modal: true,
    webPreferences: {
      nodeIntegration: true,
      enableRemoteModule: true,
      contextIsolation: false
    }
  });

  loadRoute(configWindow, 'config');

  configWindow.webContents.on('did-finish-load', async () => {
    await ipc.callRenderer(configWindow, 'plugin', pluginName);
    configWindow.show();
  });

  await pEvent(configWindow, 'closed');
};

const openEditorConfigWindow = async (pluginName: string, serviceTitle: string, editorWindow: BrowserWindow) => {
  const configWindow = new BrowserWindow({
    width: 480,
    height: 420,
    resizable: false,
    movable: false,
    minimizable: false,
    maximizable: false,
    fullscreenable: false,
    titleBarStyle: 'hiddenInset',
    show: false,
    parent: editorWindow,
    modal: true,
    webPreferences: {
      nodeIntegration: true,
      enableRemoteModule: true,
      contextIsolation: false
    }
  });

  loadRoute(configWindow, 'config');

  configWindow.webContents.on('did-finish-load', async () => {
    await ipc.callRenderer(configWindow, 'edit-service', {pluginName, serviceTitle});
    configWindow.show();
  });

  await pEvent(configWindow, 'closed');
};

ipc.answerRenderer('open-edit-config', async ({pluginName, serviceTitle}, window) => {
  return openEditorConfigWindow(pluginName, serviceTitle, window);
});

windowManager.setConfig({
  open: openConfigWindow
});


================================================
FILE: main/windows/cropper.ts
================================================

import {windowManager} from './manager';
import {BrowserWindow, systemPreferences, dialog, screen, Display, app} from 'electron';
import delay from 'delay';

import {settings} from '../common/settings';
import {hasMicrophoneAccess, ensureMicrophonePermissions, openSystemPreferences, ensureScreenCapturePermissions} from '../common/system-permissions';
import {loadRoute} from '../utils/routes';
import {MacWindow} from '../utils/windows';

const croppers = new Map<number, BrowserWindow>();
let notificationId: number | undefined;
let isOpen = false;

const closeAllCroppers = () => {
  screen.removeAllListeners('display-removed');
  screen.removeAllListeners('display-added');

  for (const [id, cropper] of croppers) {
    cropper.destroy();
    croppers.delete(id);
  }

  isOpen = false;

  if (notificationId !== undefined) {
    systemPreferences.unsubscribeWorkspaceNotification(notificationId);
    notificationId = undefined;
  }
};

const openCropper = (display: Display, activeDisplayId?: number) => {
  const {id, bounds} = display;
  const {x, y, width, height} = bounds;

  const cropper = new BrowserWindow({
    x,
    y,
    width,
    height,
    hasShadow: false,
    enableLargerThanScreen: true,
    resizable: false,
    movable: false,
    frame: false,
    transparent: true,
    show: false,
    webPreferences: {
      nodeIntegration: true,
      enableRemoteModule: true,
      contextIsolation: false
    }
  });

  loadRoute(cropper, 'cropper');

  cropper.setAlwaysOnTop(true, 'screen-saver', 1);

  cropper.webContents.on('did-finish-load', () => {
    const isActive = activeDisplayId === id;
    const displayInfo = {
      isActive,
      id,
      x,
      y,
      width,
      height
    };

    if (isActive) {
      const savedCropper = settings.get('cropper', {});
      // @ts-expect-error
      if (savedCropper.displayId === id) {
        // @ts-expect-error
        displayInfo.cropper = savedCropper;
      }
    }

    cropper.webContents.send('display', displayInfo);
  });

  cropper.on('closed', closeAllCroppers);
  croppers.set(id, cropper);
  return cropper;
};

const openCropperWindow = async () => {
  closeAllCroppers();
  if (windowManager.editor?.areAnyBlocking()) {
    return;
  }

  if (!ensureScreenCapturePermissions()) {
    return;
  }

  const recordAudio = settings.get('recordAudio');

  if (recordAudio && !hasMicrophoneAccess()) {
    const granted = await ensureMicrophonePermissions(async () => {
      const {response} = await dialog.showMessageBox({
        type: 'warning',
        buttons: ['Open System Preferences', 'Continue'],
        defaultId: 1,
        message: 'Kap cannot access the microphone.',
        detail: 'Audio recording is enabled but Kap does not have access to the microphone. Continue without audio or grant Kap access to the microphone the System Preferences.',
        cancelId: 2
      });

      if (response === 0) {
        openSystemPreferences('Privacy_Microphone');
        return false;
      }

      if (response === 1) {
        settings.set('recordAudio', false);
        return true;
      }

      return false;
    });

    if (!granted) {
      return;
    }
  }

  isOpen = true;

  const displays = screen.getAllDisplays();
  const activeDisplayId = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).id;

  for (const display of displays) {
    openCropper(display, activeDisplayId);
  }

  for (const cropper of croppers.values()) {
    cropper.showInactive();
  }

  croppers.get(activeDisplayId)?.focus();

  // Electron typing issue, this should be marked as returning a number
  notificationId = (systemPreferences as any).subscribeWorkspaceNotification('NSWorkspaceActiveSpaceDidChangeNotification', () => {
    closeAllCroppers();
  });

  screen.on('display-removed', (_, oldDisplay) => {
    const {id} = oldDisplay;
    const cropper = croppers.get(id);

    if (!cropper) {
      return;
    }

    const wasFocused = cropper.isFocused();

    cropper.removeAllListeners('closed');
    cropper.destroy();
    croppers.delete(id);

    if (wasFocused) {
      const activeDisplayId = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).id;
      if (croppers.has(activeDisplayId)) {
        croppers.get(activeDisplayId)?.focus();
      }
    }
  });

  screen.on('display-added', (_, newDisplay) => {
    const cropper = openCropper(newDisplay);
    cropper.showInactive();
  });
};

const preventDefault = (event: any) => event.preventDefault();

const selectApp = async (window: MacWindow, activateWindow: (ownerName: string) => Promise<void>) => {
  for (const cropper of croppers.values()) {
    cropper.prependListener('blur', preventDefault);
  }

  await activateWindow(window.ownerName);

  const {x, y, width, height, ownerName} = window;

  const display = screen.getDisplayMatching({x, y, width, height});
  const {id, bounds: {x: screenX, y: screenY}} = display;

  // For some reason this happened a bit too early without the timeout
  await delay(300);

  for (const cropper of croppers.values()) {
    cropper.removeListener('blur', preventDefault);
    cropper.webContents.send('blur');
  }

  croppers.get(id)?.focus();

  croppers.get(id)?.webContents.send('select-app', {
    ownerName,
    x: x - screenX,
    y: y - screenY,
    width,
    height
  });
};

const disableCroppers = () => {
  if (notificationId !== undefined) {
    systemPreferences.unsubscribeWorkspaceNotification(notificationId);
    notificationId = undefined;
  }

  for (const cropper of croppers.values()) {
    cropper.removeAllListeners('blur');
    cropper.setIgnoreMouseEvents(true);
    cropper.setVisibleOnAllWorkspaces(true);
  }
};

const setRecordingCroppers = () => {
  for (const cropper of croppers.values()) {
    cropper.webContents.send('start-recording');
  }
};

const isCropperOpen = () => isOpen;

app.on('before-quit', closeAllCroppers);

app.on('browser-window-created', () => {
  if (!isCropperOpen()) {
    app.dock.show();
  }
});

windowManager.setCropper({
  open: openCropperWindow,
  close: closeAllCroppers,
  selectApp,
  setRecording: setRecordingCroppers,
  isOpen: isCropperOpen,
  disable: disableCroppers
});


================================================
FILE: main/windows/dialog.ts
================================================
'use strict';

import {BrowserWindow, Rectangle} from 'electron';
import {ipcMain as ipc} from 'electron-better-ipc';
import {loadRoute} from '../utils/routes';
import {windowManager} from './manager';

const DIALOG_MIN_WIDTH = 420;
const DIALOG_MIN_HEIGHT = 150;

export type DialogOptions = any;

const showDialog = async (options: DialogOptions) => new Promise<number | void>(resolve => {
  const dialogWindow = new BrowserWindow({
    width: 1,
    height: 1,
    resizable: false,
    minimizable: false,
    maximizable: false,
    fullscreenable: false,
    vibrancy: 'window',
    show: false,
    alwaysOnTop: true,
    center: true,
    title: '',
    useContentSize: true,
    webPreferences: {
      nodeIntegration: true,
Download .txt
gitextract_nqp269lp/

├── .circleci/
│   └── config.yml
├── .editorconfig
├── .gitattributes
├── .github/
│   └── ISSUE_TEMPLATE.md
├── .gitignore
├── .npmrc
├── CODE_OF_CONDUCT.md
├── LICENSE.md
├── PRIVACY.md
├── README.md
├── build/
│   ├── 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
├── package.json
├── renderer/
│   ├── 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/
│   │   │   │   ├── index.tsx
│   │   │   │   ├── left.tsx
│   │   │   │   ├── right.tsx
│   │   │   │   ├── select.tsx
│   │   │   │   └── slider.tsx
│   │   │   ├── options-container.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
│   ├── 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
├── test/
│   ├── convert.ts
│   ├── 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
Download .txt
SYMBOL INDEX (287 symbols across 79 files)

FILE: main/common/settings.ts
  type Settings (line 18) | interface Settings {

FILE: main/common/types/base.ts
  type Format (line 3) | enum Format {
  type Encoding (line 12) | enum Encoding {
  type App (line 21) | type App = {
  type ApertureOptions (line 28) | interface ApertureOptions {
  type StartRecordingOptions (line 38) | interface StartRecordingOptions {

FILE: main/common/types/conversion-options.ts
  type CreateExportOptions (line 3) | type CreateExportOptions = {
  type EditServiceInfo (line 16) | type EditServiceInfo = {
  type ConversionOptions (line 21) | type ConversionOptions = {
  type ExportStatus (line 32) | enum ExportStatus {

FILE: main/common/types/remote-states.ts
  type RemoteState (line 5) | type RemoteState<State = any, Actions extends Record<string, (...args: a...
  type RemoteStateHook (line 10) | type RemoteStateHook<Base extends RemoteState> = Base extends RemoteStat...
  type RemoteStateHandler (line 18) | type RemoteStateHandler<Base extends RemoteState> = Base extends RemoteS...
  type ExportOptionsPlugin (line 25) | interface ExportOptionsPlugin {
  type ExportOptionsFormat (line 33) | type ExportOptionsFormat = {
  type ExportOptionsEditService (line 40) | type ExportOptionsEditService = {
  type ExportOptions (line 47) | type ExportOptions = {
  type EditorOptionsRemoteState (line 53) | type EditorOptionsRemoteState = RemoteState<ExportOptions, {
  type ExportState (line 64) | interface ExportState {
  type ExportsRemoteState (line 81) | type ExportsRemoteState = RemoteState<ExportState, {
  type ExportsListRemoteState (line 89) | type ExportsListRemoteState = RemoteState<string[]>;

FILE: main/common/types/window-states.ts
  type EditorWindowState (line 2) | interface EditorWindowState {

FILE: main/conversion.ts
  class Conversion (line 17) | class Conversion extends (EventEmitter as new () => TypedEventEmitter<Co...
    method all (line 20) | static get all() {
    method canCopy (line 28) | get canCopy() {
    method constructor (line 34) | constructor(
    method fromId (line 51) | static fromId(id: string) {
    method getOrCreate (line 55) | static getOrCreate(video: Video, format: Format, options: ConversionOp...
    method filePathExists (line 73) | async filePathExists() {
  type ConversionEvents (line 147) | interface ConversionEvents {

FILE: main/converters/process.ts
  type Mode (line 17) | enum Mode {
  type ProcessOptions (line 27) | interface ProcessOptions {

FILE: main/converters/utils.ts
  type ConvertOptions (line 4) | interface ConvertOptions {
  type ArgType (line 53) | type ArgType = string[] | string | {args: string[]; if: boolean};

FILE: main/export.ts
  type ExportOptions (line 21) | interface ExportOptions {
  class Export (line 27) | class Export extends (EventEmitter as new () => TypedEventEmitter<Export...
    method all (line 31) | static get all() {
    method constructor (line 71) | constructor(
    method fromId (line 110) | static fromId(id: string) {
    method id (line 114) | get id() {
    method canPreviewExport (line 118) | get canPreviewExport() {
    method finalFilePath (line 122) | get finalFilePath() {
    method data (line 129) | get data(): ExportState {
  type ExportEvents (line 232) | interface ExportEvents {
  type ExportsEvents (line 236) | interface ExportsEvents {

FILE: main/menus/application.ts
  type MenuModifier (line 70) | type MenuModifier = Parameters<typeof customApplicationMenu>[0];

FILE: main/menus/common.ts
  method click (line 58) | click() {

FILE: main/menus/utils.ts
  type MenuOptions (line 3) | type MenuOptions = Parameters<typeof Menu.buildFromTemplate>[0];
  type MenuItemId (line 5) | enum MenuItemId {

FILE: main/plugins/built-in/open-with-plugin.ts
  type App (line 13) | interface App {

FILE: main/plugins/config.ts
  class PluginConfig (line 6) | class PluginConfig extends Store {
    method constructor (line 15) | constructor(name: string, services: Service[]) {
    method isValid (line 71) | get isValid() {
    method validServices (line 75) | get validServices() {

FILE: main/plugins/index.ts
  type PackageJson (line 17) | type PackageJson = {
  class Plugins (line 21) | class Plugins extends EventEmitter {
    method constructor (line 34) | constructor() {
    method install (line 40) | async install(name: string): Promise<InstalledPlugin | void> {
    method uninstall (line 104) | async uninstall(name: string) {
    method upgrade (line 131) | async upgrade() {
    method getFromNpm (line 135) | async getFromNpm() {
    method allPlugins (line 156) | get allPlugins() {
    method sharePlugins (line 163) | get sharePlugins() {
    method editPlugins (line 167) | get editPlugins() {
    method recordingPlugins (line 171) | get recordingPlugins() {
    method makePluginsDir (line 179) | private makePluginsDir() {
    method modifyMainPackageJson (line 186) | private modifyMainPackageJson(modifier: (pkg: PackageJson) => void) {
    method runYarn (line 192) | private async runYarn(...args: string[]) {
    method pluginNames (line 202) | private get pluginNames() {
    method yarnInstall (line 207) | private async yarnInstall() {
    method loadPlugins (line 211) | private loadPlugins() {

FILE: main/plugins/plugin.ts
  class BasePlugin (line 18) | class BasePlugin {
    method constructor (line 25) | constructor(pluginName: string) {
    method prettyName (line 29) | get prettyName() {
    method isCompatible (line 33) | get isCompatible() {
    method repoUrl (line 37) | get repoUrl() {
    method version (line 47) | get version() {
    method description (line 51) | get description() {
    method viewOnGithub (line 55) | viewOnGithub() {
  type KapPlugin (line 62) | interface KapPlugin<Config = any> {
  class InstalledPlugin (line 72) | class InstalledPlugin extends BasePlugin {
    method constructor (line 83) | constructor(pluginName: string, customPath?: string) {
    method isSymLink (line 115) | get isSymLink() {
    method shareServices (line 119) | get shareServices() {
    method editServices (line 123) | get editServices() {
    method recordServices (line 127) | get recordServices() {
    method allServices (line 131) | get allServices() {
    method isValid (line 139) | get isValid() {
    method recordServicesWithStatus (line 143) | get recordServicesWithStatus() {
  class NpmPlugin (line 196) | class NpmPlugin extends BasePlugin {
    method constructor (line 199) | constructor(json: readPkg.NormalizedPackageJson, kap: {version?: strin...

FILE: main/plugins/service-context.ts
  type ServiceContextOptions (line 11) | interface ServiceContextOptions {
  class ServiceContext (line 15) | class ServiceContext {
    method constructor (line 21) | constructor(options: ServiceContextOptions) {
  type ShareServiceContextOptions (line 55) | interface ShareServiceContextOptions extends ServiceContextOptions {
  class ShareServiceContext (line 64) | class ShareServiceContext extends ServiceContext {
    method constructor (line 69) | constructor(options: ShareServiceContextOptions) {
    method format (line 74) | get format() {
    method prettyFormat (line 78) | get prettyFormat() {
    method defaultFileName (line 82) | get defaultFileName() {
  type EditServiceContextOptions (line 104) | interface EditServiceContextOptions extends ServiceContextOptions {
  class EditServiceContext (line 121) | class EditServiceContext extends ServiceContext {
    method constructor (line 126) | constructor(options: EditServiceContextOptions) {
    method inputPath (line 131) | get inputPath() {
    method outputPath (line 135) | get outputPath() {
    method exportOptions (line 139) | get exportOptions() {
    method convert (line 143) | get convert() {
  type RecordServiceState (line 161) | type RecordServiceState<PersistedState extends Record<string, unknown> =...
  type RecordServiceContextOptions (line 165) | interface RecordServiceContextOptions<State extends RecordServiceState> ...
  class RecordServiceContext (line 171) | class RecordServiceContext<State extends RecordServiceState> extends Ser...
    method constructor (line 174) | constructor(options: RecordServiceContextOptions<State>) {
    method state (line 179) | get state() {
    method apertureOptions (line 183) | get apertureOptions() {
    method setRecordingName (line 187) | get setRecordingName() {

FILE: main/plugins/service.ts
  type Service (line 7) | interface Service<Config = any> {
  type ShareService (line 13) | interface ShareService<Config = any> extends Service<Config> {
  type EditService (line 18) | interface EditService<Config = any> extends Service<Config> {
  type RecordServiceHook (line 22) | type RecordServiceHook = 'willStartRecording' | 'didStartRecording' | 'd...
  type RecordService (line 24) | type RecordService<Config = any> = Service<Config> & {

FILE: main/recording-history.ts
  type PastRecording (line 20) | interface PastRecording {
  type ActiveRecording (line 26) | interface ActiveRecording extends PastRecording {

FILE: main/remote-states/utils.ts
  type RemoteState (line 13) | type RemoteState<State, Actions extends Record<string, Function>> = (sen...

FILE: main/utils/ajv.ts
  type Schema (line 5) | type Schema<T extends {'required': boolean} = any> = Except<JSONSchema<T...
  class CustomAjv (line 29) | class CustomAjv extends Ajv {
    method constructor (line 30) | constructor(options: Options) {

FILE: main/utils/errors.ts
  constant MAX_RETRIES (line 15) | const MAX_RETRIES = 10;
  constant ERRORS_TO_IGNORE (line 17) | const ERRORS_TO_IGNORE = [
  type SentryIssue (line 25) | type SentryIssue = {

FILE: main/utils/macos-release.ts
  function macosRelease (line 27) | function macosRelease(release?: string) {

FILE: main/utils/notifications.ts
  type Action (line 7) | interface Action extends NotificationAction {
  type NotificationOptions (line 11) | interface NotificationOptions extends NotificationConstructorOptions {
  type NotificationPromise (line 17) | type NotificationPromise = Promise<void> & {

FILE: main/utils/sentry.ts
  constant SENTRY_PUBLIC_DSN (line 8) | const SENTRY_PUBLIC_DSN = 'https://2dffdbd619f34418817f4db3309299ce@sent...

FILE: main/utils/windows.ts
  type MacWindow (line 8) | interface MacWindow {
  constant APP_BLACKLIST (line 19) | const APP_BLACKLIST = [

FILE: main/video.ts
  type VideoOptions (line 11) | interface VideoOptions {
  class Video (line 21) | class Video {
    method constructor (line 38) | constructor(options: VideoOptions) {
    method fromId (line 53) | static fromId(id: string) {
    method getOrCreate (line 57) | static getOrCreate(options: VideoOptions) {
    method getFps (line 61) | async getFps() {
    method exists (line 69) | async exists() {
    method getEncoding (line 78) | async getEncoding() {
    method getPreviewPath (line 86) | async getPreviewPath() {
    method getDragIcon (line 102) | async getDragIcon({width, height}: {width: number; height: number}) {
    method generatePreviewImage (line 113) | async generatePreviewImage() {
    method whenReady (line 121) | async whenReady() {
    method whenPreviewReady (line 125) | async whenPreviewReady() {
    method openEditorWindow (line 129) | async openEditorWindow() {
    method collectInfo (line 133) | private async collectInfo() {
  class Recording (line 147) | class Recording extends Video {
    method constructor (line 150) | constructor(options: VideoOptions & {apertureOptions: ApertureOptions}) {

FILE: main/windows/dialog.ts
  constant DIALOG_MIN_WIDTH (line 8) | const DIALOG_MIN_WIDTH = 420;
  constant DIALOG_MIN_HEIGHT (line 9) | const DIALOG_MIN_HEIGHT = 150;
  type DialogOptions (line 11) | type DialogOptions = any;

FILE: main/windows/editor.ts
  constant OPTIONS_BAR_HEIGHT (line 13) | const OPTIONS_BAR_HEIGHT = 48;
  constant VIDEO_ASPECT (line 14) | const VIDEO_ASPECT = 9 / 16;
  constant MIN_VIDEO_WIDTH (line 15) | const MIN_VIDEO_WIDTH = 900;
  constant MIN_VIDEO_HEIGHT (line 16) | const MIN_VIDEO_HEIGHT = MIN_VIDEO_WIDTH * VIDEO_ASPECT;
  constant MIN_WINDOW_HEIGHT (line 17) | const MIN_WINDOW_HEIGHT = MIN_VIDEO_HEIGHT + OPTIONS_BAR_HEIGHT;

FILE: main/windows/kap-window.ts
  type KapWindowOptions (line 7) | interface KapWindowOptions<State> extends Electron.BrowserWindowConstruc...
  class KapWindow (line 24) | class KapWindow<State = any> {
    method constructor (line 42) | constructor(private readonly props: KapWindowOptions<State>) {
    method getAllWindows (line 75) | static getAllWindows() {
    method fromId (line 79) | static fromId(id: number) {
    method webContents (line 83) | get webContents() {
    method setupWindow (line 122) | private async setupWindow() {

FILE: main/windows/manager.ts
  type EditorManager (line 7) | interface EditorManager {
  type CropperManager (line 12) | interface CropperManager {
  type ConfigManager (line 21) | interface ConfigManager {
  type DialogManager (line 25) | interface DialogManager {
  type ExportsManager (line 29) | interface ExportsManager {
  type PreferencesManager (line 34) | interface PreferencesManager {
  class WindowManager (line 39) | class WindowManager {

FILE: main/windows/preferences.ts
  type PreferencesWindowOptions (line 12) | type PreferencesWindowOptions = any;

FILE: renderer/components/action-bar/controls/advanced.js
  class Left (line 64) | class Left extends React.Component {
    method getDerivedStateFromProps (line 69) | static getDerivedStateFromProps(nextProps, previousState) {
    method render (line 97) | render() {
  class Right (line 192) | class Right extends React.Component {
    method constructor (line 193) | constructor(props) {
    method render (line 248) | render() {

FILE: renderer/components/action-bar/controls/main.js
  class Left (line 35) | class Left extends React.Component {
    method getDerivedStateFromProps (line 38) | static getDerivedStateFromProps(nextProps, previousState) {
    method render (line 49) | render() {
  class Right (line 87) | class Right extends React.Component {
    method render (line 93) | render() {

FILE: renderer/components/action-bar/index.js
  constant TRANSITION_DURATION (line 11) | const TRANSITION_DURATION = 0.2;
  class ActionBar (line 13) | class ActionBar extends React.Component {
    method render (line 23) | render() {

FILE: renderer/components/config/index.js
  class Config (line 7) | class Config extends React.Component {
    method render (line 8) | render() {

FILE: renderer/components/config/tab.js
  class Tab (line 113) | class Tab extends React.Component {
    method render (line 114) | render() {

FILE: renderer/components/cropper/cursor.js
  class Cursor (line 8) | class Cursor extends React.Component {
    method render (line 11) | render() {

FILE: renderer/components/cropper/handles.js
  class Handle (line 7) | class Handle extends React.Component {
    method render (line 17) | render() {
  class Handles (line 92) | class Handles extends React.Component {
    method render (line 99) | render() {

FILE: renderer/components/cropper/index.js
  class Cropper (line 9) | class Cropper extends React.Component {
    method render (line 10) | render() {

FILE: renderer/components/cropper/overlay.js
  class Overlay (line 14) | class Overlay extends React.Component {
    method render (line 23) | render() {

FILE: renderer/components/editor/controls/preview.tsx
  type Props (line 5) | type Props = {

FILE: renderer/components/editor/options-container.tsx
  type EditService (line 11) | type EditService = EditorOptionsState['editServices'][0];
  type SharePlugin (line 13) | type SharePlugin = {

FILE: renderer/components/editor/options/select.tsx
  type Option (line 6) | type Option<T> = {
  type Separator (line 17) | type Separator = {
  type Props (line 27) | interface Props<T> {

FILE: renderer/components/editor/options/slider.tsx
  type Props (line 5) | interface Props {

FILE: renderer/components/icon-menu.tsx
  type MenuProps (line 5) | type MenuProps = {
  type IconMenuProps (line 11) | type IconMenuProps = SvgProps & MenuProps & {

FILE: renderer/components/keyboard-number-input.js
  class KeyboardNumberInput (line 5) | class KeyboardNumberInput extends React.Component {
    method constructor (line 6) | constructor(props) {
    method render (line 15) | render() {

FILE: renderer/components/preferences/categories/category.js
  class Category (line 4) | class Category extends React.Component {
    method render (line 5) | render() {

FILE: renderer/components/preferences/categories/general.js
  class General (line 16) | class General extends React.Component {
    method componentDidMount (line 25) | componentDidMount() {
    method render (line 35) | render() {

FILE: renderer/components/preferences/categories/index.js
  constant CATEGORIES (line 10) | const CATEGORIES = [
  class Categories (line 20) | class Categories extends React.Component {
    method componentDidUpdate (line 21) | componentDidUpdate(previousProps) {
    method render (line 28) | render() {

FILE: renderer/components/preferences/categories/plugins/index.js
  class Plugins (line 9) | class Plugins extends React.Component {
    method render (line 16) | render() {

FILE: renderer/components/preferences/item/index.js
  class Item (line 31) | class Item extends React.Component {
    method render (line 37) | render() {

FILE: renderer/components/preferences/item/select.js
  class Select (line 8) | class Select extends React.Component {
    method constructor (line 15) | constructor(props) {
    method getDerivedStateFromProps (line 22) | static getDerivedStateFromProps(nextProps) {
    method render (line 57) | render() {

FILE: renderer/components/preferences/item/switch.js
  class Switch (line 8) | class Switch extends React.Component {
    method render (line 9) | render() {

FILE: renderer/components/preferences/navigation.js
  constant CATEGORIES (line 10) | const CATEGORIES = [
  class PreferencesNavigation (line 20) | class PreferencesNavigation extends React.Component {
    method render (line 25) | render() {

FILE: renderer/components/traffic-lights.tsx
  type TrafficLightsProps (line 4) | interface TrafficLightsProps {

FILE: renderer/components/window-header.js
  class WindowHeader (line 4) | class WindowHeader extends React.Component {
    method render (line 5) | render() {

FILE: renderer/containers/action-bar.js
  class ActionBarContainer (line 7) | class ActionBarContainer extends Container {
    method constructor (line 10) | constructor() {

FILE: renderer/containers/config.js
  class ConfigContainer (line 4) | class ConfigContainer extends Container {
    method setPlugin (line 9) | setPlugin(pluginName) {

FILE: renderer/containers/cropper.js
  class CropperContainer (line 39) | class CropperContainer extends Container {
    method constructor (line 42) | constructor() {

FILE: renderer/containers/cursor.js
  class CursorContainer (line 3) | class CursorContainer extends Container {

FILE: renderer/containers/preferences.js
  constant SETTINGS_ANALYTICS_BLACKLIST (line 8) | const SETTINGS_ANALYTICS_BLACKLIST = ['kapturesDir'];
  class PreferencesContainer (line 10) | class PreferencesContainer extends Container {

FILE: renderer/hooks/editor/use-conversion.tsx
  type UseConversion (line 6) | type UseConversion = ReturnType<typeof useConversion>;
  type UseConversionState (line 7) | type UseConversionState = UseConversion['state'];

FILE: renderer/hooks/editor/use-editor-options.tsx
  type EditorOptionsState (line 17) | type EditorOptionsState = ReturnType<typeof useEditorOptions>['state'];

FILE: renderer/hooks/editor/use-window-size.tsx
  constant CONVERSION_WIDTH (line 5) | const CONVERSION_WIDTH = 370;
  constant CONVERSION_HEIGHT (line 6) | const CONVERSION_HEIGHT = 392;
  constant DEFAULT_EDITOR_WIDTH (line 7) | const DEFAULT_EDITOR_WIDTH = 768;
  constant DEFAULT_EDITOR_HEIGHT (line 8) | const DEFAULT_EDITOR_HEIGHT = 480;

FILE: renderer/hooks/exports/use-exports-list.tsx
  type UseExportsList (line 6) | type UseExportsList = ReturnType<typeof useExportsList>;
  type UseExportsListState (line 7) | type UseExportsListState = UseExportsList['state'];

FILE: renderer/hooks/use-confirmation.tsx
  type UseConfirmationOptions (line 3) | interface UseConfirmationOptions {

FILE: renderer/next.config.js
  method webpack (line 5) | webpack(config, options) {

FILE: renderer/pages/config.js
  class ConfigPage (line 11) | class ConfigPage extends React.Component {
    method componentDidMount (line 14) | componentDidMount() {
    method render (line 26) | render() {

FILE: renderer/pages/cropper.js
  class CropperPage (line 24) | class CropperPage extends React.Component {
    method constructor (line 29) | constructor(props) {
    method componentDidMount (line 68) | componentDidMount() {
    method componentWillUnmount (line 73) | componentWillUnmount() {
    method render (line 105) | render() {

FILE: renderer/pages/preferences.js
  class PreferencesPage (line 14) | class PreferencesPage extends React.Component {
    method componentDidMount (line 17) | componentDidMount() {
    method render (line 27) | render() {

FILE: renderer/utils/combine-unstated-containers.tsx
  type ContainerOrWithInitialState (line 4) | type ContainerOrWithInitialState<T = any> = Container<any, T> | [Contain...

FILE: renderer/utils/inputs.js
  constant RATIOS (line 131) | const RATIOS = [

FILE: renderer/utils/sentry-error-boundary.tsx
  constant SENTRY_PUBLIC_DSN (line 6) | const SENTRY_PUBLIC_DSN = 'https://2dffdbd619f34418817f4db3309299ce@sent...
  class SentryErrorBoundary (line 8) | class SentryErrorBoundary extends React.Component<{children: React.React...
    method constructor (line 9) | constructor(props) {
    method componentDidCatch (line 24) | componentDidCatch(error, errorInfo) {
    method render (line 38) | render() {

FILE: renderer/vectors/svg.tsx
  type SvgProps (line 96) | interface SvgProps {

FILE: test/mocks/electron-store.ts
  class Store (line 30) | class Store {
    method store (line 36) | get store() {

FILE: test/mocks/service-context.ts
  class ShareServiceContext (line 2) | class ShareServiceContext {}
  class RecordServiceContext (line 3) | class RecordServiceContext {}
  class EditServiceContext (line 4) | class EditServiceContext {}

FILE: test/mocks/video.ts
  class Video (line 9) | class Video {
    method constructor (line 13) | constructor(...args: any[]) {

FILE: test/mocks/window-manager.ts
  class MockWindowManager (line 8) | class MockWindowManager implements SetOptional<
Condensed preview — 225 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (569K chars).
[
  {
    "path": ".circleci/config.yml",
    "chars": 1295,
    "preview": "version: 2\njobs:\n  build:\n    macos:\n      xcode: '13.4.1'\n    steps:\n      - checkout\n      - run: yarn\n      - run: mk"
  },
  {
    "path": ".editorconfig",
    "chars": 147,
    "preview": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ni"
  },
  {
    "path": ".gitattributes",
    "chars": 19,
    "preview": "* text=auto eol=lf\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "chars": 1051,
    "preview": "<!--\nThank you for taking the time to report an issue! ❤️\n\nBefore you continue; please make sure you've searched our exi"
  },
  {
    "path": ".gitignore",
    "chars": 68,
    "preview": "node_modules\n/renderer/out\n/renderer/.next\n/app/dist\n/dist\n/dist-js\n"
  },
  {
    "path": ".npmrc",
    "chars": 19,
    "preview": "package-lock=false\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "chars": 3214,
    "preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, w"
  },
  {
    "path": "LICENSE.md",
    "chars": 1099,
    "preview": "MIT License\n\nCopyright (c) Wulkano hello@wulkano.com (https://wulkano.com)\n\nPermission is hereby granted, free of charge"
  },
  {
    "path": "PRIVACY.md",
    "chars": 5166,
    "preview": "# Your privacy\n\nEven though Kap is open-source and not in the business of monetizing the product itself nor your data, d"
  },
  {
    "path": "README.md",
    "chars": 2277,
    "preview": "<p align=\"center\">\n  <img src=\"https://getkap.co/static/favicon/kap.svg\" height=\"64\">\n  <h3 align=\"center\">Kap</h3>\n  <p"
  },
  {
    "path": "build/entitlements.mac.inherit.plist",
    "chars": 540,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
  },
  {
    "path": "contributing.md",
    "chars": 487,
    "preview": "# Contributing\n\n1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and "
  },
  {
    "path": "docs/plugins.md",
    "chars": 22537,
    "preview": "# Plugins\n\nThe Kap plugin system lets you create custom share targets that appear in the editor export menu. You could, "
  },
  {
    "path": "main/aperture.ts",
    "chars": 8352,
    "preview": "import {windowManager} from './windows/manager';\nimport {setRecordingTray, setPausedTray, disableTray, resetTray} from '"
  },
  {
    "path": "main/common/accelerator-validator.ts",
    "chars": 2527,
    "preview": "// The goal of this file is validating accelerator values we receive from the user\n// to make sure that they are can be "
  },
  {
    "path": "main/common/analytics.ts",
    "chars": 926,
    "preview": "'use strict';\n\nimport util from 'electron-util';\nimport {parse} from 'semver';\nimport {settings} from './settings';\n\n// "
  },
  {
    "path": "main/common/constants.ts",
    "chars": 423,
    "preview": "import {Format} from './types';\n\nexport const supportedVideoExtensions = ['mp4', 'mov', 'm4v'];\n\nconst formatExtensions "
  },
  {
    "path": "main/common/flags.ts",
    "chars": 251,
    "preview": "import Store from 'electron-store';\n\nexport const flags = new Store<{\n  backgroundEditorConversion: boolean;\n  editorDra"
  },
  {
    "path": "main/common/settings.ts",
    "chars": 3127,
    "preview": "'use strict';\n\nimport {homedir} from 'os';\nimport Store from 'electron-store';\n\nconst {defaultInputDeviceId} = require('"
  },
  {
    "path": "main/common/system-permissions.ts",
    "chars": 2744,
    "preview": "import {systemPreferences, shell, dialog, app} from 'electron';\nconst {hasScreenCapturePermission, hasPromptedForPermiss"
  },
  {
    "path": "main/common/types/base.ts",
    "chars": 801,
    "preview": "import {Rectangle} from 'electron';\n\nexport enum Format {\n  gif = 'gif',\n  hevc = 'hevc',\n  mp4 = 'mp4',\n  webm = 'webm'"
  },
  {
    "path": "main/common/types/conversion-options.ts",
    "chars": 683,
    "preview": "import {App, Format} from './base';\n\nexport type CreateExportOptions = {\n  filePath: string;\n  conversionOptions: Conver"
  },
  {
    "path": "main/common/types/index.ts",
    "chars": 128,
    "preview": "export * from './base';\nexport * from './remote-states';\nexport * from './conversion-options';\nexport * from './window-s"
  },
  {
    "path": "main/common/types/remote-states.ts",
    "chars": 2282,
    "preview": "import {App, Format} from './base';\nimport {ExportStatus} from './conversion-options';\n\n// eslint-disable-next-line @typ"
  },
  {
    "path": "main/common/types/window-states.ts",
    "chars": 144,
    "preview": "\nexport interface EditorWindowState {\n  fps: number;\n  previewFilePath: string;\n  filePath: string;\n  title: string;\n  c"
  },
  {
    "path": "main/conversion.ts",
    "chars": 4035,
    "preview": "import fs from 'fs';\nimport {app, clipboard} from 'electron';\nimport {EventEmitter} from 'events';\nimport {ConversionOpt"
  },
  {
    "path": "main/converters/h264.ts",
    "chars": 8358,
    "preview": "import PCancelable from 'p-cancelable';\nimport tempy from 'tempy';\nimport {compress, convert} from './process';\nimport {"
  },
  {
    "path": "main/converters/index.ts",
    "chars": 4982,
    "preview": "import path from 'path';\nimport tempy from 'tempy';\nimport {Encoding, Format} from '../common/types';\nimport {track} fro"
  },
  {
    "path": "main/converters/process.ts",
    "chars": 3442,
    "preview": "import util from 'electron-util';\nimport execa from 'execa';\nimport moment from 'moment';\nimport PCancelable from 'p-can"
  },
  {
    "path": "main/converters/utils.ts",
    "chars": 2090,
    "preview": "import moment from 'moment';\nimport prettyMilliseconds from 'pretty-ms';\n\nexport interface ConvertOptions {\n  inputPath:"
  },
  {
    "path": "main/export.ts",
    "chars": 9703,
    "preview": "import {ipcMain, dialog, app} from 'electron';\nimport {EventEmitter} from 'events';\nimport PCancelable, {CancelError, On"
  },
  {
    "path": "main/global-accelerators.ts",
    "chars": 2608,
    "preview": "import {globalShortcut} from 'electron';\nimport {ipcMain as ipc} from 'electron-better-ipc';\nimport {settings} from './c"
  },
  {
    "path": "main/index.ts",
    "chars": 3786,
    "preview": "import {app} from 'electron';\nimport {is, enforceMacOSAppLocation} from 'electron-util';\nimport log from 'electron-log';"
  },
  {
    "path": "main/menus/application.ts",
    "chars": 1579,
    "preview": "import {appMenu} from 'electron-util';\nimport {getAboutMenuItem, getExportHistoryMenuItem, getOpenFileMenuItem, getPrefe"
  },
  {
    "path": "main/menus/cog.ts",
    "chars": 2786,
    "preview": "import {Menu} from 'electron';\nimport {MenuItemId, MenuOptions} from './utils';\nimport {getAboutMenuItem, getExportHisto"
  },
  {
    "path": "main/menus/common.ts",
    "chars": 2696,
    "preview": "import delay from 'delay';\nimport {app, dialog} from 'electron';\nimport {openNewGitHubIssue} from 'electron-util';\nimpor"
  },
  {
    "path": "main/menus/record.ts",
    "chars": 1479,
    "preview": "import {Menu} from 'electron';\nimport {MenuItemId, MenuOptions} from './utils';\nimport {pauseRecording, resumeRecording,"
  },
  {
    "path": "main/menus/utils.ts",
    "chars": 915,
    "preview": "import {Menu} from 'electron';\n\nexport type MenuOptions = Parameters<typeof Menu.buildFromTemplate>[0];\n\nexport enum Men"
  },
  {
    "path": "main/plugins/built-in/copy-to-clipboard-plugin.ts",
    "chars": 683,
    "preview": "import {clipboard} from 'electron';\nimport {ShareServiceContext} from '../service-context';\n\nconst plist = require('plis"
  },
  {
    "path": "main/plugins/built-in/open-with-plugin.ts",
    "chars": 1375,
    "preview": "import {ShareServiceContext} from '../service-context';\nimport path from 'path';\nimport {getFormatExtension} from '../.."
  },
  {
    "path": "main/plugins/built-in/save-file-plugin.ts",
    "chars": 2198,
    "preview": "'use strict';\n\nimport {BrowserWindow, dialog} from 'electron';\nimport {ShareServiceContext} from '../service-context';\ni"
  },
  {
    "path": "main/plugins/config.ts",
    "chars": 2057,
    "preview": "import {ValidateFunction} from 'ajv';\nimport Store, {Schema as JSONSchema} from 'electron-store';\nimport Ajv, {Schema} f"
  },
  {
    "path": "main/plugins/index.ts",
    "chars": 6517,
    "preview": "import {app} from 'electron';\nimport {EventEmitter} from 'events';\nimport path from 'path';\nimport fs from 'fs';\nimport "
  },
  {
    "path": "main/plugins/plugin.ts",
    "chars": 5858,
    "preview": "import {app, shell} from 'electron';\nimport macosVersion from 'macos-version';\nimport semver from 'semver';\nimport path "
  },
  {
    "path": "main/plugins/service-context.ts",
    "chars": 4499,
    "preview": "import {app, clipboard} from 'electron';\nimport Store from 'electron-store';\nimport got, {GotFn, GotPromise} from 'got';"
  },
  {
    "path": "main/plugins/service.ts",
    "chars": 1058,
    "preview": "\nimport PCancelable from 'p-cancelable';\nimport {Format} from '../common/types';\nimport {Schema} from '../utils/ajv';\nim"
  },
  {
    "path": "main/recording-history.ts",
    "chars": 8528,
    "preview": "/* eslint-disable array-element-newline */\n'use strict';\n\nimport {shell, clipboard} from 'electron';\nimport fs from 'fs'"
  },
  {
    "path": "main/remote-states/editor-options.ts",
    "chars": 4032,
    "preview": "import Store from 'electron-store';\nimport {EditorOptionsRemoteState, ExportOptions, ExportOptionsPlugin, Format, Remote"
  },
  {
    "path": "main/remote-states/exports-list.ts",
    "chars": 646,
    "preview": "import {ExportsListRemoteState, RemoteStateHandler} from '../common/types';\nimport Export from '../export';\n\nconst expor"
  },
  {
    "path": "main/remote-states/exports.ts",
    "chars": 1597,
    "preview": "import {shell} from 'electron';\nimport {ExportsRemoteState, RemoteStateHandler} from '../common/types';\nimport Export fr"
  },
  {
    "path": "main/remote-states/index.ts",
    "chars": 398,
    "preview": "import setupRemoteState from './setup-remote-state';\n\nconst remoteStateNames = ['editor-options', 'exports', 'exports-li"
  },
  {
    "path": "main/remote-states/setup-remote-state.ts",
    "chars": 2056,
    "preview": "import {RemoteState, getChannelNames} from './utils';\nimport {ipcMain} from 'electron-better-ipc';\nimport {BrowserWindow"
  },
  {
    "path": "main/remote-states/utils.ts",
    "chars": 727,
    "preview": "import {Promisable} from 'type-fest';\n\nexport const getChannelName = (name: string, action: string) => `kap-remote-state"
  },
  {
    "path": "main/tray.ts",
    "chars": 2910,
    "preview": "'use strict';\n\nimport {Tray} from 'electron';\nimport {KeyboardEvent} from 'electron/main';\nimport path from 'path';\nimpo"
  },
  {
    "path": "main/utils/ajv.ts",
    "chars": 1265,
    "preview": "import Ajv, {Options} from 'ajv';\nimport {Schema as JSONSchema} from 'electron-store';\nimport {Except} from 'type-fest';"
  },
  {
    "path": "main/utils/deep-linking.ts",
    "chars": 1137,
    "preview": "import {windowManager} from '../windows/manager';\n\nconst pluginPromises = new Map<string, (path: string) => void>();\n\nco"
  },
  {
    "path": "main/utils/devices.ts",
    "chars": 2165,
    "preview": "import {hasMicrophoneAccess} from '../common/system-permissions';\nimport * as audioDevices from 'macos-audio-devices';\ni"
  },
  {
    "path": "main/utils/dock.ts",
    "chars": 554,
    "preview": "import {app} from 'electron';\nimport {Promisable} from 'type-fest';\n\nexport const ensureDockIsShowing = async (action: ("
  },
  {
    "path": "main/utils/encoding.ts",
    "chars": 884,
    "preview": "/* eslint-disable array-element-newline */\n\nimport path from 'path';\nimport execa from 'execa';\nimport tempy from 'tempy"
  },
  {
    "path": "main/utils/errors.ts",
    "chars": 5643,
    "preview": "import path from 'path';\nimport {clipboard, shell, app} from 'electron';\nimport ensureError from 'ensure-error';\nimport "
  },
  {
    "path": "main/utils/ffmpeg-path.ts",
    "chars": 153,
    "preview": "import ffmpeg from 'ffmpeg-static';\nimport util from 'electron-util';\n\nconst ffmpegPath = util.fixPathForAsarUnpack(ffmp"
  },
  {
    "path": "main/utils/format-time.ts",
    "chars": 597,
    "preview": "import moment from 'moment';\n\nconst formatTime = (time: number, options: any) => {\n  options = {\n    showMilliseconds: f"
  },
  {
    "path": "main/utils/formats.ts",
    "chars": 328,
    "preview": "import {Format} from '../common/types';\n\nconst formats = new Map([\n  [Format.gif, 'GIF'],\n  [Format.hevc, 'MP4 (H265)'],"
  },
  {
    "path": "main/utils/fps.ts",
    "chars": 303,
    "preview": "import execa from 'execa';\nimport ffmpegPath from './ffmpeg-path';\n\nconst getFps = async (filePath: string) => {\n  try {"
  },
  {
    "path": "main/utils/image-preview.ts",
    "chars": 1345,
    "preview": "/* eslint-disable array-element-newline */\n\nimport {BrowserWindow, dialog} from 'electron';\nimport execa from 'execa';\ni"
  },
  {
    "path": "main/utils/macos-release.ts",
    "chars": 882,
    "preview": "// Vendored: https://github.com/sindresorhus/macos-release\n\n'use strict';\nconst os = require('os');\n\nconst nameMap = {\n "
  },
  {
    "path": "main/utils/notifications.ts",
    "chars": 1819,
    "preview": "import {Notification, NotificationConstructorOptions, NotificationAction, app} from 'electron';\n\n// Need to persist the "
  },
  {
    "path": "main/utils/open-files.ts",
    "chars": 526,
    "preview": "'use strict';\nimport path from 'path';\nimport {supportedVideoExtensions} from '../common/constants';\nimport {Video} from"
  },
  {
    "path": "main/utils/protocol.ts",
    "chars": 386,
    "preview": "import {protocol} from 'electron';\n\nexport const setupProtocol = () => {\n  // Fix protocol issue in order to support loa"
  },
  {
    "path": "main/utils/routes.ts",
    "chars": 517,
    "preview": "import {app, BrowserWindow} from 'electron';\nimport {is} from 'electron-util';\n\nexport const loadRoute = (window: Browse"
  },
  {
    "path": "main/utils/sentry.ts",
    "chars": 518,
    "preview": "'use strict';\n\nimport {app} from 'electron';\nimport {is} from 'electron-util';\nimport * as Sentry from '@sentry/electron"
  },
  {
    "path": "main/utils/shortcut-to-accelerator.ts",
    "chars": 410,
    "preview": "\nexport const shortcutToAccelerator = (shortcut: any) => {\n  const {metaKey, altKey, ctrlKey, shiftKey, character} = sho"
  },
  {
    "path": "main/utils/timestamped-name.ts",
    "chars": 199,
    "preview": "import moment from 'moment';\n\nexport const generateTimestampedName = (title = 'Kapture', extension = '') => `${title} ${"
  },
  {
    "path": "main/utils/track-duration.ts",
    "chars": 547,
    "preview": "// TODO: Add interface to aperture-node for getting recording duration instead of using this https://github.com/wulkano/"
  },
  {
    "path": "main/utils/windows.ts",
    "chars": 2528,
    "preview": "import {Menu, MenuItem, nativeImage} from 'electron';\nimport Store from 'electron-store';\nimport {windowManager} from '."
  },
  {
    "path": "main/video.ts",
    "chars": 4152,
    "preview": "import path from 'path';\nimport getFps from './utils/fps';\nimport {getEncoding, convertToH264} from './utils/encoding';\n"
  },
  {
    "path": "main/windows/config.ts",
    "chars": 2000,
    "preview": "'use strict';\n\nimport {BrowserWindow} from 'electron';\nimport {ipcMain as ipc} from 'electron-better-ipc';\nimport pEvent"
  },
  {
    "path": "main/windows/cropper.ts",
    "chars": 6192,
    "preview": "\nimport {windowManager} from './manager';\nimport {BrowserWindow, systemPreferences, dialog, screen, Display, app} from '"
  },
  {
    "path": "main/windows/dialog.ts",
    "chars": 2575,
    "preview": "'use strict';\n\nimport {BrowserWindow, Rectangle} from 'electron';\nimport {ipcMain as ipc} from 'electron-better-ipc';\nim"
  },
  {
    "path": "main/windows/editor.ts",
    "chars": 4314,
    "preview": "import {EditorWindowState} from '../common/types';\nimport type {Video} from '../video';\nimport KapWindow from './kap-win"
  },
  {
    "path": "main/windows/exports.ts",
    "chars": 1122,
    "preview": "import KapWindow from './kap-window';\nimport {windowManager} from './manager';\n\nlet exportsKapWindow: KapWindow | undefi"
  },
  {
    "path": "main/windows/kap-window.ts",
    "chars": 4527,
    "preview": "import electron, {app, BrowserWindow, Menu} from 'electron';\nimport {ipcMain as ipc} from 'electron-better-ipc';\nimport "
  },
  {
    "path": "main/windows/load.ts",
    "chars": 121,
    "preview": "import './editor';\nimport './cropper';\nimport './config';\nimport './dialog';\nimport './exports';\nimport './preferences';"
  },
  {
    "path": "main/windows/manager.ts",
    "chars": 1851,
    "preview": "import type {BrowserWindow} from 'electron';\nimport {MacWindow} from '../utils/windows';\nimport type {Video} from '../vi"
  },
  {
    "path": "main/windows/preferences.ts",
    "chars": 1833,
    "preview": "import {BrowserWindow} from 'electron';\nimport {promisify} from 'util';\nimport pEvent from 'p-event';\n\nimport {ipcMain a"
  },
  {
    "path": "maintaining.md",
    "chars": 1794,
    "preview": "# Maintaining\n\n## Developing Kap\n\nRun `yarn dev` in one terminal tab to start watch mode, and in another tab, run `yarn "
  },
  {
    "path": "package.json",
    "chars": 8479,
    "preview": "{\n  \"name\": \"kap\",\n  \"productName\": \"Kap\",\n  \"version\": \"3.6.0\",\n  \"description\": \"An open-source screen recorder built "
  },
  {
    "path": "renderer/components/action-bar/controls/advanced.js",
    "chars": 9175,
    "preview": "import PropTypes from 'prop-types';\nimport React from 'react';\nimport css from 'styled-jsx/css';\n\nimport {\n  SwapIcon,\n "
  },
  {
    "path": "renderer/components/action-bar/controls/main.js",
    "chars": 3447,
    "preview": "import electron from 'electron';\nimport PropTypes from 'prop-types';\nimport React from 'react';\nimport css from 'styled-"
  },
  {
    "path": "renderer/components/action-bar/index.js",
    "chars": 3949,
    "preview": "import classNames from 'classnames';\nimport PropTypes from 'prop-types';\nimport React from 'react';\n\nimport {connect, Cr"
  },
  {
    "path": "renderer/components/action-bar/record-button.js",
    "chars": 6937,
    "preview": "import electron from 'electron';\nimport React, {useState, useEffect} from 'react';\nimport PropTypes from 'prop-types';\ni"
  },
  {
    "path": "renderer/components/config/index.js",
    "chars": 4846,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nimport {connect, ConfigContainer} from '../../containers"
  },
  {
    "path": "renderer/components/config/tab.js",
    "chars": 5927,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport Linkify from 'react-linkify';\n\nimport Item, {Link}"
  },
  {
    "path": "renderer/components/cropper/cursor.js",
    "chars": 1667,
    "preview": "import electron from 'electron';\nimport PropTypes from 'prop-types';\nimport React from 'react';\nimport classNames from '"
  },
  {
    "path": "renderer/components/cropper/handles.js",
    "chars": 4520,
    "preview": "import React from 'react';\nimport classNames from 'classnames';\nimport PropTypes from 'prop-types';\n\nimport {connect, Cr"
  },
  {
    "path": "renderer/components/cropper/index.js",
    "chars": 955,
    "preview": "import PropTypes from 'prop-types';\nimport React from 'react';\n\nimport {connect, CropperContainer} from '../../container"
  },
  {
    "path": "renderer/components/cropper/overlay.js",
    "chars": 4390,
    "preview": "import classNames from 'classnames';\nimport PropTypes from 'prop-types';\nimport React from 'react';\n\nimport {\n  connect,"
  },
  {
    "path": "renderer/components/dialog/actions.js",
    "chars": 1669,
    "preview": "import React, {useState, useEffect, useRef} from 'react';\nimport PropTypes from 'prop-types';\n\nconst Actions = ({buttons"
  },
  {
    "path": "renderer/components/dialog/body.js",
    "chars": 1284,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nconst Body = ({title, message, detail}) => {\n  return (\n"
  },
  {
    "path": "renderer/components/dialog/icon.js",
    "chars": 341,
    "preview": "import React from 'react';\n\nconst Icon = () => {\n  return (\n    <div>\n      <img src=\"/static/kap-icon.png\"/>\n      <sty"
  },
  {
    "path": "renderer/components/editor/controls/left.tsx",
    "chars": 1398,
    "preview": "import VideoControlsContainer from '../video-controls-container';\nimport VideoTimeContainer from '../video-time-containe"
  },
  {
    "path": "renderer/components/editor/controls/play-bar.tsx",
    "chars": 12724,
    "preview": "import VideoTimeContainer from '../video-time-container';\nimport {useState, useRef} from 'react';\nimport VideoControlsCo"
  },
  {
    "path": "renderer/components/editor/controls/preview.tsx",
    "chars": 3643,
    "preview": "import formatTime from '../../../utils/format-time';\nimport {useRef, useEffect} from 'react';\nimport useEditorWindowStat"
  },
  {
    "path": "renderer/components/editor/controls/right.tsx",
    "chars": 1659,
    "preview": "import {VolumeHighIcon, VolumeOffIcon} from '../../../vectors';\nimport VideoControlsContainer from '../video-controls-co"
  },
  {
    "path": "renderer/components/editor/conversion/conversion-details.tsx",
    "chars": 1862,
    "preview": "import {UseConversionState} from 'hooks/editor/use-conversion';\n\nconst ConversionDetails = ({conversion, showInFolder}: "
  },
  {
    "path": "renderer/components/editor/conversion/index.tsx",
    "chars": 2087,
    "preview": "import {ExportStatus} from 'common/types';\nimport useConversion from 'hooks/editor/use-conversion';\nimport useConversion"
  },
  {
    "path": "renderer/components/editor/conversion/title-bar.tsx",
    "chars": 3664,
    "preview": "import TrafficLights from 'components/traffic-lights';\nimport {BackPlainIcon, MoreIcon} from 'vectors';\nimport {UseConve"
  },
  {
    "path": "renderer/components/editor/conversion/video-preview.tsx",
    "chars": 5519,
    "preview": "import {CancelIcon, SpinnerIcon} from 'vectors';\nimport {UseConversion, UseConversionState} from 'hooks/editor/use-conve"
  },
  {
    "path": "renderer/components/editor/editor-preview.tsx",
    "chars": 1748,
    "preview": "import TrafficLights from '../traffic-lights';\nimport VideoPlayer from './video-player';\nimport Options from './options'"
  },
  {
    "path": "renderer/components/editor/index.tsx",
    "chars": 1633,
    "preview": "import useConversionIdContext from 'hooks/editor/use-conversion-id';\nimport useEditorWindowState from 'hooks/editor/use-"
  },
  {
    "path": "renderer/components/editor/options/index.tsx",
    "chars": 636,
    "preview": "import LeftOptions from './left';\nimport RightOptions from './right';\n\nconst Options = () => {\n  return (\n    <div class"
  },
  {
    "path": "renderer/components/editor/options/left.tsx",
    "chars": 10394,
    "preview": "import css from 'styled-jsx/css';\nimport KeyboardNumberInput from '../../keyboard-number-input';\nimport Slider from './s"
  },
  {
    "path": "renderer/components/editor/options/right.tsx",
    "chars": 11803,
    "preview": "import {GearIcon} from '../../../vectors';\nimport OptionsContainer from '../options-container';\nimport Select from './se"
  },
  {
    "path": "renderer/components/editor/options/select.tsx",
    "chars": 3611,
    "preview": "import {DropdownArrowIcon, CancelIcon} from '../../../vectors';\nimport classNames from 'classnames';\nimport {useRef} fro"
  },
  {
    "path": "renderer/components/editor/options/slider.tsx",
    "chars": 9410,
    "preview": "import {TooltipIcon} from '../../../vectors';\nimport {useState, useEffect} from 'react';\nimport {shake} from '../../../u"
  },
  {
    "path": "renderer/components/editor/options-container.tsx",
    "chars": 4226,
    "preview": "import {useState, useEffect, useMemo} from 'react';\nimport {createContainer} from 'unstated-next';\nimport {debounce, Deb"
  },
  {
    "path": "renderer/components/editor/video-controls-container.tsx",
    "chars": 2632,
    "preview": "import {createContainer} from 'unstated-next';\nimport electron from 'electron';\nimport {useRef, useState, useEffect} fro"
  },
  {
    "path": "renderer/components/editor/video-metadata-container.tsx",
    "chars": 1291,
    "preview": "import {createContainer} from 'unstated-next';\nimport {useRef, useState} from 'react';\nimport {useShowWindow} from '../."
  },
  {
    "path": "renderer/components/editor/video-player.tsx",
    "chars": 3205,
    "preview": "import Video from './video';\nimport LeftControls from './controls/left';\nimport RightControls from './controls/right';\ni"
  },
  {
    "path": "renderer/components/editor/video-time-container.tsx",
    "chars": 1947,
    "preview": "import {createContainer} from 'unstated-next';\nimport {useRef, useState, useEffect} from 'react';\n\nconst useVideoTime = "
  },
  {
    "path": "renderer/components/editor/video.tsx",
    "chars": 2601,
    "preview": "import {useRef, useEffect} from 'react';\nimport VideoTimeContainer from './video-time-container';\nimport VideoMetadataCo"
  },
  {
    "path": "renderer/components/exports/export.tsx",
    "chars": 5283,
    "preview": "import electron from 'electron';\nimport React, {useCallback, useMemo} from 'react';\nimport classNames from 'classnames';"
  },
  {
    "path": "renderer/components/exports/index.tsx",
    "chars": 590,
    "preview": "import React, {useMemo} from 'react';\n\nimport useExportsList from '../../hooks/exports/use-exports-list';\nimport Export "
  },
  {
    "path": "renderer/components/exports/progress.tsx",
    "chars": 1301,
    "preview": "import PropTypes from 'prop-types';\nimport React from 'react';\n\nimport {SpinnerIcon} from '../../vectors';\n\nexport const"
  },
  {
    "path": "renderer/components/icon-menu.tsx",
    "chars": 1386,
    "preview": "import {MenuItemConstructorOptions} from 'electron';\nimport React, {FunctionComponent, useRef} from 'react';\nimport {Svg"
  },
  {
    "path": "renderer/components/keyboard-number-input.js",
    "chars": 723,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport {handleInputKeyPress} from '../utils/inputs';\n\ncla"
  },
  {
    "path": "renderer/components/preferences/categories/category.js",
    "chars": 577,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nclass Category extends React.Component {\n  render() {\n  "
  },
  {
    "path": "renderer/components/preferences/categories/general.js",
    "chars": 7826,
    "preview": "import electron from 'electron';\nimport React from 'react';\nimport PropTypes from 'prop-types';\nimport tildify from 'til"
  },
  {
    "path": "renderer/components/preferences/categories/index.js",
    "chars": 1603,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport {ipcRenderer as ipc} from 'electron-better-ipc';\n\n"
  },
  {
    "path": "renderer/components/preferences/categories/plugins/index.js",
    "chars": 6311,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nimport {connect, PreferencesContainer} from '../../../.."
  },
  {
    "path": "renderer/components/preferences/categories/plugins/plugin.js",
    "chars": 3645,
    "preview": "import electron from 'electron';\nimport React from 'react';\nimport PropTypes from 'prop-types';\n\nimport Item from '../.."
  },
  {
    "path": "renderer/components/preferences/categories/plugins/tab.js",
    "chars": 2835,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nimport Plugin from './plugin';\n\nexport const EmptyTab = "
  },
  {
    "path": "renderer/components/preferences/item/button.js",
    "chars": 876,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nconst Button = ({title, onClick, tabIndex}) => (\n  <butt"
  },
  {
    "path": "renderer/components/preferences/item/color-picker.js",
    "chars": 2350,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport classNames from 'classnames';\n\nconst ColorPicker ="
  },
  {
    "path": "renderer/components/preferences/item/index.js",
    "chars": 5397,
    "preview": "import electron from 'electron';\nimport React from 'react';\nimport PropTypes from 'prop-types';\nimport classNames from '"
  },
  {
    "path": "renderer/components/preferences/item/select.js",
    "chars": 3648,
    "preview": "import electron from 'electron';\nimport PropTypes from 'prop-types';\nimport React from 'react';\n\nimport {DropdownArrowIc"
  },
  {
    "path": "renderer/components/preferences/item/switch.js",
    "chars": 2847,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport classNames from 'classnames';\n\nimport {SpinnerIcon"
  },
  {
    "path": "renderer/components/preferences/navigation.js",
    "chars": 3051,
    "preview": "import classNames from 'classnames';\nimport React from 'react';\nimport PropTypes from 'prop-types';\n\nimport {connect, Pr"
  },
  {
    "path": "renderer/components/preferences/shortcut-input.js",
    "chars": 7264,
    "preview": "import React, {useRef, useEffect, useState} from 'react';\nimport PropTypes from 'prop-types';\nimport classNames from 'cl"
  },
  {
    "path": "renderer/components/traffic-lights.tsx",
    "chars": 5054,
    "preview": "import {remote} from 'electron';\nimport {useState, useEffect, FunctionComponent} from 'react';\n\ninterface TrafficLightsP"
  },
  {
    "path": "renderer/components/window-header.js",
    "chars": 1083,
    "preview": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nclass WindowHeader extends React.Component {\n  render() "
  },
  {
    "path": "renderer/containers/action-bar.js",
    "chars": 3317,
    "preview": "import electron from 'electron';\nimport {Container} from 'unstated';\n\nconst barWidth = 464;\nconst barHeight = 64;\n\nexpor"
  },
  {
    "path": "renderer/containers/config.js",
    "chars": 1617,
    "preview": "import electron from 'electron';\nimport {Container} from 'unstated';\n\nexport default class ConfigContainer extends Conta"
  },
  {
    "path": "renderer/containers/cropper.js",
    "chars": 14910,
    "preview": "import electron from 'electron';\nimport nearestNormalAspectRatio from 'nearest-normal-aspect-ratio';\nimport {Container} "
  },
  {
    "path": "renderer/containers/cursor.js",
    "chars": 603,
    "preview": "import {Container} from 'unstated';\n\nexport default class CursorContainer extends Container {\n  state = {\n    observers:"
  },
  {
    "path": "renderer/containers/index.js",
    "chars": 905,
    "preview": "import React from 'react';\nimport {Subscribe} from 'unstated';\n\nimport CropperContainer from './cropper';\nimport CursorC"
  },
  {
    "path": "renderer/containers/preferences.js",
    "chars": 8413,
    "preview": "import electron from 'electron';\nimport {Container} from 'unstated';\nimport {ipcRenderer as ipc} from 'electron-better-i"
  },
  {
    "path": "renderer/hooks/dark-mode.tsx",
    "chars": 358,
    "preview": "import {useState, useEffect} from 'react';\n\nconst useDarkMode = () => {\n  const {darkMode} = require('electron-util');\n "
  },
  {
    "path": "renderer/hooks/editor/use-conversion-id.tsx",
    "chars": 1311,
    "preview": "import {CreateExportOptions} from 'common/types';\nimport {ipcRenderer} from 'electron-better-ipc';\nimport {createContext"
  },
  {
    "path": "renderer/hooks/editor/use-conversion.tsx",
    "chars": 336,
    "preview": "import {ExportsRemoteState} from 'common/types';\nimport createRemoteStateHook from 'hooks/use-remote-state';\n\nconst useC"
  },
  {
    "path": "renderer/hooks/editor/use-editor-options.tsx",
    "chars": 461,
    "preview": "import {EditorOptionsRemoteState} from 'common/types';\nimport createRemoteStateHook from 'hooks/use-remote-state';\n\ncons"
  },
  {
    "path": "renderer/hooks/editor/use-editor-window-state.tsx",
    "chars": 207,
    "preview": "import useWindowState from 'hooks/window-state';\nimport {EditorWindowState} from 'common/types';\n\nconst useEditorWindowS"
  },
  {
    "path": "renderer/hooks/editor/use-share-plugins.tsx",
    "chars": 2301,
    "preview": "import OptionsContainer from 'components/editor/options-container';\nimport {remote} from 'electron';\nimport {ipcRenderer"
  },
  {
    "path": "renderer/hooks/editor/use-window-size.tsx",
    "chars": 1244,
    "preview": "import {remote} from 'electron';\nimport {useEffect, useRef} from 'react';\nimport {resizeKeepingCenter} from 'utils/windo"
  },
  {
    "path": "renderer/hooks/exports/use-exports-list.tsx",
    "chars": 355,
    "preview": "import {ExportsListRemoteState} from 'common/types';\nimport createRemoteStateHook from 'hooks/use-remote-state';\n\nconst "
  },
  {
    "path": "renderer/hooks/use-confirmation.tsx",
    "chars": 749,
    "preview": "import {useCallback} from 'react';\n\ninterface UseConfirmationOptions {\n  message: string;\n  detail?: string;\n  confirmBu"
  },
  {
    "path": "renderer/hooks/use-current-window.tsx",
    "chars": 113,
    "preview": "import {remote} from 'electron';\n\nexport const useCurrentWindow = () => {\n  return remote.getCurrentWindow();\n};\n"
  },
  {
    "path": "renderer/hooks/use-keyboard-action.tsx",
    "chars": 1005,
    "preview": "import {DependencyList, useEffect, useMemo} from 'react';\n\nexport const useKeyboardAction = (keyOrFilter: string | ((key"
  },
  {
    "path": "renderer/hooks/use-remote-state.tsx",
    "chars": 2179,
    "preview": "import {useState, useEffect, useRef} from 'react';\nimport {ipcRenderer} from 'electron-better-ipc';\nimport {RemoteState,"
  },
  {
    "path": "renderer/hooks/use-show-window.tsx",
    "chars": 240,
    "preview": "import {useEffect} from 'react';\nimport {ipcRenderer} from 'electron-better-ipc';\n\nexport const useShowWindow = (show: b"
  },
  {
    "path": "renderer/hooks/window-state.tsx",
    "chars": 888,
    "preview": "import {createContext, useContext, useState, useEffect, ReactNode} from 'react';\nimport {ipcRenderer as ipc} from 'elect"
  },
  {
    "path": "renderer/next-env.d.ts",
    "chars": 75,
    "preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/types/global\" />\n"
  },
  {
    "path": "renderer/next.config.js",
    "chars": 682,
    "preview": "const path = require('path');\n\nmodule.exports = (nextConfig) => {\n  return Object.assign({}, nextConfig, {\n    webpack(c"
  },
  {
    "path": "renderer/pages/_app.tsx",
    "chars": 986,
    "preview": "import {AppProps} from 'next/app';\nimport {useState, useEffect} from 'react';\nimport useDarkMode from '../hooks/dark-mod"
  },
  {
    "path": "renderer/pages/config.js",
    "chars": 2013,
    "preview": "import React from 'react';\nimport {Provider} from 'unstated';\nimport {ipcRenderer as ipc} from 'electron-better-ipc';\n\ni"
  },
  {
    "path": "renderer/pages/cropper.js",
    "chars": 5981,
    "preview": "import electron from 'electron';\nimport React from 'react';\nimport {Provider} from 'unstated';\n\nimport Overlay from '../"
  },
  {
    "path": "renderer/pages/dialog.js",
    "chars": 3231,
    "preview": "import Actions from '../components/dialog/actions';\nimport Icon from '../components/dialog/icon';\nimport Body from '../c"
  },
  {
    "path": "renderer/pages/editor.tsx",
    "chars": 2560,
    "preview": "import Head from 'next/head';\n// Import EditorPreview from '../components/editor/editor-preview';\nimport combineUnstated"
  },
  {
    "path": "renderer/pages/exports.tsx",
    "chars": 658,
    "preview": "import React from 'react';\n\nimport WindowHeader from '../components/window-header';\nimport Exports from '../components/e"
  },
  {
    "path": "renderer/pages/preferences.js",
    "chars": 4238,
    "preview": "import React from 'react';\nimport {Provider} from 'unstated';\nimport classNames from 'classnames';\nimport {ipcRenderer a"
  },
  {
    "path": "renderer/tsconfig.eslint.json",
    "chars": 121,
    "preview": "{\n  \"extends\": \"./tsconfig.json\",\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \"**/*.js\"\n  ]\n}"
  },
  {
    "path": "renderer/tsconfig.json",
    "chars": 881,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"es2019\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n "
  },
  {
    "path": "renderer/utils/combine-unstated-containers.tsx",
    "chars": 909,
    "preview": "import React, {FunctionComponent, PropsWithChildren} from 'react';\nimport {Container} from 'unstated-next';\n\ntype Contai"
  },
  {
    "path": "renderer/utils/format-time.js",
    "chars": 553,
    "preview": "import moment from 'moment';\n\nconst formatTime = (time, options) => {\n  options = {\n    showMilliseconds: false,\n    ..."
  },
  {
    "path": "renderer/utils/global-styles.tsx",
    "chars": 7050,
    "preview": "import {useState, useEffect, useMemo} from 'react';\nimport useDarkMode from '../hooks/dark-mode';\nimport {remote} from '"
  },
  {
    "path": "renderer/utils/inputs.js",
    "chars": 4984,
    "preview": "import electron from 'electron';\nimport _ from 'lodash';\n\nlet screenWidth = 0;\nlet screenHeight = 0;\n\nexport const setSc"
  },
  {
    "path": "renderer/utils/sentry-error-boundary.tsx",
    "chars": 1279,
    "preview": "import React from 'react';\nimport * as Sentry from '@sentry/browser';\nimport electron from 'electron';\nimport type {api "
  },
  {
    "path": "renderer/utils/window.ts",
    "chars": 415,
    "preview": "\nexport const resizeKeepingCenter = (\n  bounds: Electron.Rectangle,\n  newSize: {width: number; height: number}\n): Electr"
  },
  {
    "path": "renderer/vectors/applications.js",
    "chars": 316,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\n// eslint-disable-next-line unicorn/prevent-abbreviations\nconst App"
  },
  {
    "path": "renderer/vectors/back-plain.tsx",
    "chars": 320,
    "preview": "import React, {FunctionComponent} from 'react';\nimport Svg, {SvgProps} from './svg';\n\nconst BackPlainIcon: FunctionCompo"
  },
  {
    "path": "renderer/vectors/back.js",
    "chars": 219,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst BackIcon = props => (\n  <Svg {...props}>\n    <path d=\"M20 11v"
  },
  {
    "path": "renderer/vectors/cancel.js",
    "chars": 255,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst CancelIcon = props => (\n  <Svg {...props}>\n    <path d=\"M19 6"
  },
  {
    "path": "renderer/vectors/crop.js",
    "chars": 236,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst CropIcon = props => (\n  <Svg {...props}>\n    <path d=\"M7 17V1"
  },
  {
    "path": "renderer/vectors/dropdown-arrow.js",
    "chars": 189,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst DropdownArrowIcon = props => (\n  <Svg {...props}>\n    <path d"
  },
  {
    "path": "renderer/vectors/edit.js",
    "chars": 299,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst EditIcon = props => (\n  <Svg {...props}>\n    <path d=\"M3 17.3"
  },
  {
    "path": "renderer/vectors/error.js",
    "chars": 373,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst ErrorIcon = props => (\n  <Svg {...props}>\n    <path opacity=\""
  },
  {
    "path": "renderer/vectors/exit-fullscreen.js",
    "chars": 250,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst ExitFullscreenIcon = props => (\n  <Svg {...props}>\n    <path "
  },
  {
    "path": "renderer/vectors/fullscreen.js",
    "chars": 298,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst FullscrenIcon = props => (\n  <Svg {...props}>\n    <path d=\"M3"
  },
  {
    "path": "renderer/vectors/gear.js",
    "chars": 773,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst GearIcon = props => (\n  <Svg {...props}>\n    <path d=\"M12 15."
  },
  {
    "path": "renderer/vectors/help.js",
    "chars": 389,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst HelpIcon = props => (\n  <Svg {...props}>\n    <path d=\"M0 0h24"
  },
  {
    "path": "renderer/vectors/index.js",
    "chars": 1378,
    "preview": "import ApplicationsIcon from './applications';\nimport BackIcon from './back';\nimport CropIcon from './crop';\nimport Drop"
  },
  {
    "path": "renderer/vectors/link.js",
    "chars": 357,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst LinkIcon = props => (\n  <Svg {...props}>\n    <path d=\"M16 6h-"
  },
  {
    "path": "renderer/vectors/more.js",
    "chars": 344,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst MoreIcon = props => (\n  <Svg {...props}>\n    <path d=\"M0 0h24"
  },
  {
    "path": "renderer/vectors/open-config.js",
    "chars": 249,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst OpenConfigIcon = props => (\n  <Svg {...props}>\n    <path d=\"M"
  },
  {
    "path": "renderer/vectors/open-on-github.js",
    "chars": 563,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst OpenOnGithubIcon = props => (\n  <Svg {...props}>\n    <path d="
  },
  {
    "path": "renderer/vectors/pause.js",
    "chars": 187,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst PauseIcon = props => (\n  <Svg {...props}>\n    <path d=\"M14,19"
  },
  {
    "path": "renderer/vectors/play.js",
    "chars": 185,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst PlayIcon = props => (\n  <Svg {...props}>\n    <path d=\"M8,5.14"
  },
  {
    "path": "renderer/vectors/plugins.js",
    "chars": 274,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst PluginsIcon = props => (\n  <Svg {...props}>\n    <path d=\"M19 "
  },
  {
    "path": "renderer/vectors/settings.js",
    "chars": 893,
    "preview": "import React from 'react';\nimport Svg from './svg';\n\nconst SettingsIcon = props => (\n  <Svg {...props}>\n    <path d=\"M0 "
  },
  {
    "path": "renderer/vectors/spinner.js",
    "chars": 806,
    "preview": "import PropTypes from 'prop-types';\nimport React from 'react';\n\nconst SpinnerIcon = ({stroke = 'var(--kap)'}) => (\n  <sv"
  }
]

// ... and 25 more files (download for full content)

About this extraction

This page contains the full source code of the wulkano/Kap GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 225 files (521.9 KB), approximately 138.4k tokens, and a symbol index with 287 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!