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 ================================================ **macOS version:** **Kap version:** #### Steps to reproduce #### Current behaviour #### Expected behaviour #### Workaround ================================================ 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 ================================================

Kap

An open-source screen recorder built with web technology

Build Status XO code style

[![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/`. 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 ================================================ com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.allow-dyld-environment-variables com.apple.security.device.audio-input com.apple.security.device.camera ================================================ 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. ``` | 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`.
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' } }; ``` #### `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(); let apertureOptions: ApertureOptions; let recordingName: string | undefined; let past: number | undefined; const setRecordingName = (name: string) => { recordingName = name; }; const serializeEditPluginState = () => { const result: Record | 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({ 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 any> = {}> = { actions: Actions; state: State; }; export type RemoteStateHook = Base extends RemoteState ? ( Actions & { state: State; isLoading: boolean; refreshState: () => void; } ) : never; export type RemoteStateHandler = Base extends RemoteState ? (sendUpdate: (state: State, id?: string) => void) => { actions: { [Key in keyof Actions]: Actions[Key] extends (...args: any[]) => any ? (id: string, ...args: Parameters) => 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 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 void; cancel: () => void; retry: () => void; openInEditor: () => void; showInFolder: () => void; }>; export type ExportsListRemoteState = RemoteState; ================================================ 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) { static conversionMap = new Map(); 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; 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 & {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; croppingHandler: (options: ConvertOptions) => PCancelable; }, 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(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).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((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; } export default class Export extends (EventEmitter as new () => TypedEventEmitter) { static exportsMap = new Map(); static events = new EventEmitter() as TypedEventEmitter; 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; 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 = { 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 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('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(`shortcuts.${setting}`); if (shortcut) { registerShortcut(shortcut, action); } } } else { globalShortcut.unregisterAll(); } }; export const initializeGlobalAccelerators = () => { ipc.answerRenderer('update-shortcut', ({setting, shortcut}) => { const oldShortcut = settings.get(`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) => void) => { const menu = defaultApplicationMenu(); modifier(menu); return menu; }; export type MenuModifier = Parameters[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 => [ 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 => { 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 = ` **macOS version:** ${release.name} (${release.version}) **Kap version:** ${app.getVersion()} #### Steps to reproduce #### Current behavior #### Expected behavior #### Workaround `; ================================================ 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[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; validate: ValidateFunction; }>; constructor(name: string, services: Service[]) { const defaults = {}; const validators = services .filter(({config}) => Boolean(config)) .map(service => { const config = service.config as Record; const schema: Record> = {}; 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; }; 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 { 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>({ 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 { shareServices?: Array>; editServices?: Array>; recordServices?: Array>; didConfigChange?: (newValue: Readonly | undefined, oldValue: Readonly | undefined, config: Store) => void | Promise; didInstall?: (config: Store) => void | Promise; willUninstall?: (config: Store) => void | Promise; } 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> = []; config: Store; private readonly plugin: InstalledPlugin; constructor(options: ServiceContextOptions) { this.plugin = options.plugin; this.config = this.plugin.config; } request = (...args: Parameters) => { 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; 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; 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 = Record> = { persistedState?: PersistedState; }; export interface RecordServiceContextOptions extends ServiceContextOptions { apertureOptions: ApertureOptions; state: State; setRecordingName: (name: string) => void; } export class RecordServiceContext extends ServiceContext { private readonly options: RecordServiceContextOptions; constructor(options: RecordServiceContextOptions) { 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 { title: string; configDescription?: string; config?: {[P in keyof Config]: Schema}; } export interface ShareService extends Service { formats: Format[]; action: (context: ShareServiceContext) => PromiseLike | PCancelable; } export interface EditService extends Service { action: (context: EditServiceContext) => PromiseLike | PCancelable; } export type RecordServiceHook = 'willStartRecording' | 'didStartRecording' | 'didStopRecording'; export type RecordService = Service & { [key in RecordServiceHook]: ((context: RecordServiceContext) => PromiseLike) | undefined; } & { willEnable?: () => PromiseLike; cleanUp?: (persistedState: Record) => 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>; } 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) => { 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 => { 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}}>({ 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 = (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 = 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 = 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 = 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 >(name: string, callback: RemoteState) => { const channelNames = getChannelNames(name); const renderersMap = new Map>(); 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> = (sendUpdate: (state?: State, id?: string) => void) => Promisable<{ getState: (id?: string) => Promisable; 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(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 = Except, '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 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 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) => { 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 => { 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} **macOS version:** ${release.name} (${release.version}) **Kap version:** ${app.getVersion()} \`\`\` ${title} ${errorStack} \`\`\` `; 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(); interface Action extends NotificationAction { action?: () => void | Promise; } interface NotificationOptions extends NotificationConstructorOptions { actions?: Action[]; click?: () => void | Promise; show?: boolean; } type NotificationPromise = Promise & { 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; }>({ 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(); 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; private readonly previewReadyPromise: Promise; 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(); 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) => { 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(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, enableRemoteModule: true, contextIsolation: false } }); loadRoute(dialogWindow, 'dialog'); let buttons: any[]; let wasActionTaken; const updateUi = async (newOptions: DialogOptions) => { wasActionTaken = true; buttons = newOptions.buttons.map((button: any) => { if (typeof button === 'string') { return {label: button}; } return button; }); const cancelButton = buttons.findIndex(({label}) => label === 'Cancel'); const {width, height} = await ipc.callRenderer(dialogWindow, 'data', { cancelId: cancelButton > 0 ? cancelButton : undefined, ...options, ...newOptions, buttons: buttons.map(({label, activeLabel}) => ({label, activeLabel})), id: dialogWindow.id }); const bounds = dialogWindow.getBounds(); const titleBarHeight = dialogWindow.getSize()[1] - dialogWindow.getContentSize()[1]; dialogWindow.setBounds({ width: Math.max(width, bounds.width, DIALOG_MIN_WIDTH), height: Math.max(height + titleBarHeight, bounds.height, DIALOG_MIN_HEIGHT) }); }; const unsubscribe = ipc.answerRenderer(`dialog-action-${dialogWindow.id}`, async (index: number) => { if (buttons[index]) { if (buttons[index].action) { wasActionTaken = false; await buttons[index].action(cleanup, updateUi); if (!wasActionTaken) { cleanup(index); } } else { cleanup(index); } } else { cleanup(); } }); const cleanup = (value?: number) => { wasActionTaken = true; unsubscribe(); dialogWindow.close(); resolve(value); }; dialogWindow.webContents.on('did-finish-load', async () => { await updateUi(options); dialogWindow.show(); }); }); windowManager.setDialog({ open: showDialog }); ================================================ FILE: main/windows/editor.ts ================================================ import {EditorWindowState} from '../common/types'; import type {Video} from '../video'; import KapWindow from './kap-window'; import {MenuItemId} from '../menus/utils'; import {BrowserWindow, dialog} from 'electron'; import {is} from 'electron-util'; import fs from 'fs'; import {saveSnapshot} from '../utils/image-preview'; import {windowManager} from './manager'; const pify = require('pify'); const OPTIONS_BAR_HEIGHT = 48; const VIDEO_ASPECT = 9 / 16; const MIN_VIDEO_WIDTH = 900; const MIN_VIDEO_HEIGHT = MIN_VIDEO_WIDTH * VIDEO_ASPECT; const MIN_WINDOW_HEIGHT = MIN_VIDEO_HEIGHT + OPTIONS_BAR_HEIGHT; const editors = new Map(); const editorsWithNotSavedDialogs = new Map(); const open = async (video: Video) => { if (editors.has(video.filePath)) { editors.get(video.filePath).show(); return; } // TODO: Make this smarter so the editor can show with a spinner while the preview is generated for longer preview conversions (like ProRes) await video.whenPreviewReady(); const editorKapWindow = new KapWindow({ title: video.title, // TODO: Return those to the original values when we are able to resize below min size // Upstream issue: https://github.com/electron/electron/issues/27025 // minWidth: MIN_VIDEO_WIDTH, // minHeight: MIN_WINDOW_HEIGHT, minWidth: 360, minHeight: 392, width: MIN_VIDEO_WIDTH, height: MIN_WINDOW_HEIGHT, backgroundColor: '#222222', webPreferences: { webSecurity: !is.development // Disable webSecurity in dev to load video over file:// protocol while serving over insecure http, this is not needed in production where we use file:// protocol for html serving. }, frame: false, transparent: true, vibrancy: 'window', route: 'editor', initialState: { previewFilePath: video.previewPath!, filePath: video.filePath, fps: video.fps!, title: video.title }, menu: defaultMenu => { if (!video.isNewRecording) { return; } const fileMenu = defaultMenu.find(item => item.id === MenuItemId.file); if (fileMenu) { const submenu = fileMenu.submenu as Electron.MenuItemConstructorOptions[]; const index = submenu.findIndex(item => item.id === MenuItemId.openVideo); if (index > -1) { submenu.splice(index + 1, 0, { type: 'separator' }, { label: 'Save Original…', id: MenuItemId.saveOriginal, accelerator: 'Command+S', click: async () => saveOriginal(video) }); } } } }); const editorWindow = editorKapWindow.browserWindow; editors.set(video.filePath, editorWindow); if (video.isNewRecording) { editorWindow.setDocumentEdited(true); editorWindow.on('close', (event: any) => { editorsWithNotSavedDialogs.set(video.filePath, true); const buttonIndex = dialog.showMessageBoxSync(editorWindow, { type: 'question', buttons: [ 'Discard', 'Cancel' ], defaultId: 0, cancelId: 1, message: 'Are you sure that you want to discard this recording?', detail: 'You will no longer be able to edit and export the original recording.' }); if (buttonIndex === 1) { event.preventDefault(); } editorsWithNotSavedDialogs.delete(video.filePath); }); } editorWindow.on('closed', () => { editors.delete(video.filePath); }); editorWindow.on('blur', () => { editorKapWindow.callRenderer('blur'); }); editorWindow.on('focus', () => { editorKapWindow.callRenderer('focus'); }); editorKapWindow.answerRenderer('save-snapshot', (time: number) => { saveSnapshot(video, time); }); }; const saveOriginal = async (video: Video) => { const {filePath} = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow()!, { defaultPath: `${video.title}.mp4` }); if (filePath) { await pify(fs.copyFile)(video.filePath, filePath, fs.constants.COPYFILE_FICLONE); } }; const areAnyBlocking = () => { if (editorsWithNotSavedDialogs.size > 0) { const [path] = editorsWithNotSavedDialogs.keys(); editors.get(path).focus(); return true; } return false; }; windowManager.setEditor({ open, areAnyBlocking }); ================================================ FILE: main/windows/exports.ts ================================================ import KapWindow from './kap-window'; import {windowManager} from './manager'; let exportsKapWindow: KapWindow | undefined; const openExportsWindow = async () => { if (exportsKapWindow) { exportsKapWindow.browserWindow.focus(); } else { exportsKapWindow = new KapWindow({ title: 'Exports', width: 320, height: 360, resizable: false, maximizable: false, fullscreenable: false, titleBarStyle: 'hiddenInset', frame: false, transparent: true, vibrancy: 'window', webPreferences: { nodeIntegration: true, contextIsolation: false }, route: 'exports' }); const exportsWindow = exportsKapWindow.browserWindow; const titleBarHeight = 37; exportsWindow.setSheetOffset(titleBarHeight); exportsWindow.on('close', () => { exportsKapWindow = undefined; }); await exportsKapWindow.whenReady(); } return exportsKapWindow.browserWindow; }; const getExportsWindow = () => exportsKapWindow?.browserWindow; windowManager.setExports({ open: openExportsWindow, get: getExportsWindow }); ================================================ FILE: main/windows/kap-window.ts ================================================ import electron, {app, BrowserWindow, Menu} from 'electron'; import {ipcMain as ipc} from 'electron-better-ipc'; import pEvent from 'p-event'; import {customApplicationMenu, defaultApplicationMenu, MenuModifier} from '../menus/application'; import {loadRoute} from '../utils/routes'; interface KapWindowOptions extends Electron.BrowserWindowConstructorOptions { route: string; waitForMount?: boolean; initialState?: State; menu?: MenuModifier; dock?: boolean; } // TODO: remove this when all windows use KapWindow app.on('browser-window-focus', (_, window) => { if (!KapWindow.fromId(window.id)) { Menu.setApplicationMenu(Menu.buildFromTemplate(defaultApplicationMenu())); } }); // Has to be named BrowserWindow because of // https://github.com/electron/electron/blob/master/lib/browser/api/browser-window.ts#L82 export default class KapWindow { static defaultOptions: Partial> = { waitForMount: true, dock: true, menu: defaultMenu => defaultMenu }; private static readonly windows = new Map(); browserWindow: BrowserWindow; state?: State; menu: Menu = Menu.buildFromTemplate(defaultApplicationMenu()); readonly id: number; private readonly readyPromise: Promise; private readonly cleanupMethods: Array<() => void> = []; private readonly options: KapWindowOptions; constructor(private readonly props: KapWindowOptions) { const { route, waitForMount, initialState, ...rest } = props; this.browserWindow = new BrowserWindow({ ...rest, webPreferences: { nodeIntegration: true, enableRemoteModule: true, contextIsolation: false, ...rest.webPreferences }, show: false }); this.id = this.browserWindow.id; KapWindow.windows.set(this.id, this); this.cleanupMethods = []; this.options = { ...KapWindow.defaultOptions, ...props }; this.state = initialState; this.generateMenu(); this.readyPromise = this.setupWindow(); } static getAllWindows() { return [...this.windows.values()]; } static fromId(id: number) { return this.windows.get(id); } get webContents() { return this.browserWindow.webContents; } cleanup = () => { KapWindow.windows.delete(this.id); for (const method of this.cleanupMethods) { method(); } }; callRenderer = async (channel: string, data?: T) => { return ipc.callRenderer(this.browserWindow, channel, data); }; answerRenderer = (channel: string, callback: (data: T, window: electron.BrowserWindow) => R) => { this.cleanupMethods.push(ipc.answerRenderer(this.browserWindow, channel, callback)); }; setState = (partialState: State) => { this.state = { ...this.state, ...partialState }; this.callRenderer('kap-window-state', this.state); }; whenReady = async () => { return this.readyPromise; }; private readonly generateMenu = () => { this.menu = Menu.buildFromTemplate( customApplicationMenu(this.options.menu!) ); }; private async setupWindow() { const {waitForMount} = this.options; KapWindow.windows.set(this.id, this); this.browserWindow.on('show', () => { if (this.options.dock && !app.dock.isVisible) { app.dock.show(); } }); this.browserWindow.on('close', this.cleanup); this.browserWindow.on('closed', this.cleanup); this.browserWindow.on('focus', () => { this.generateMenu(); Menu.setApplicationMenu(this.menu); }); this.webContents.on('did-finish-load', async () => { if (this.state) { this.callRenderer('kap-window-state', this.state); } }); this.answerRenderer('kap-window-state', () => this.state); loadRoute(this.browserWindow, this.props.route); if (waitForMount) { return new Promise(resolve => { this.answerRenderer('kap-window-mount', () => { if (!this.browserWindow.isVisible()) { this.browserWindow.show(); } resolve(); }); }); } await pEvent(this.webContents, 'did-finish-load'); this.browserWindow.show(); } // Use this around any call that causes: // TypeError: Object has been destroyed // private readonly executeIfNotDestroyed = (callback: () => void) => { // if (!this.browserWindow.isDestroyed()) { // callback(); // } // }; } ================================================ FILE: main/windows/load.ts ================================================ import './editor'; import './cropper'; import './config'; import './dialog'; import './exports'; import './preferences'; ================================================ FILE: main/windows/manager.ts ================================================ import type {BrowserWindow} from 'electron'; import {MacWindow} from '../utils/windows'; import type {Video} from '../video'; import type {DialogOptions} from './dialog'; import type {PreferencesWindowOptions} from './preferences'; export interface EditorManager { open: (video: Video) => Promise; areAnyBlocking: () => boolean; } export interface CropperManager { open: () => Promise; close: () => void; disable: () => void; setRecording: () => void; isOpen: () => boolean; selectApp: (window: MacWindow, activateWindow: (ownerName: string) => Promise) => void; } export interface ConfigManager { open: (pluginName: string) => Promise; } export interface DialogManager { open: (options: DialogOptions) => Promise; } export interface ExportsManager { open: () => Promise; get: () => BrowserWindow | undefined; } export interface PreferencesManager { open: (options?: PreferencesWindowOptions) => Promise; close: () => void; } export class WindowManager { editor?: EditorManager; cropper?: CropperManager; config?: ConfigManager; dialog?: DialogManager; exports?: ExportsManager; preferences?: PreferencesManager; setEditor = (editorManager: EditorManager) => { this.editor = editorManager; }; setCropper = (cropperManager: CropperManager) => { this.cropper = cropperManager; }; setConfig = (configManager: ConfigManager) => { this.config = configManager; }; setDialog = (dialogManager: DialogManager) => { this.dialog = dialogManager; }; setExports = (exportsManager: ExportsManager) => { this.exports = exportsManager; }; setPreferences = (preferencesManager: PreferencesManager) => { this.preferences = preferencesManager; }; } export const windowManager = new WindowManager(); ================================================ FILE: main/windows/preferences.ts ================================================ import {BrowserWindow} from 'electron'; import {promisify} from 'util'; import pEvent from 'p-event'; import {ipcMain as ipc} from 'electron-better-ipc'; import {loadRoute} from '../utils/routes'; import {track} from '../common/analytics'; import {windowManager} from './manager'; let prefsWindow: BrowserWindow | undefined; export type PreferencesWindowOptions = any; const openPrefsWindow = async (options?: PreferencesWindowOptions) => { track('preferences/opened'); windowManager.cropper?.close(); if (prefsWindow) { if (options) { ipc.callRenderer(prefsWindow, 'options', options); } prefsWindow.show(); return prefsWindow; } prefsWindow = new BrowserWindow({ title: 'Preferences', width: 480, height: 480, resizable: false, minimizable: false, maximizable: false, fullscreenable: false, titleBarStyle: 'hiddenInset', show: false, frame: false, transparent: true, vibrancy: 'window', webPreferences: { nodeIntegration: true, enableRemoteModule: true, contextIsolation: false } }); const titlebarHeight = 85; prefsWindow.setSheetOffset(titlebarHeight); prefsWindow.on('close', () => { prefsWindow = undefined; }); loadRoute(prefsWindow, 'preferences'); await pEvent(prefsWindow.webContents, 'did-finish-load'); if (options) { ipc.callRenderer(prefsWindow, 'options', options); } ipc.callRenderer(prefsWindow, 'mount'); // @ts-expect-error await promisify(ipc.answerRenderer)('preferences-ready'); prefsWindow.show(); return prefsWindow; }; const closePrefsWindow = () => { if (prefsWindow) { prefsWindow.close(); } }; ipc.answerRenderer('open-preferences', openPrefsWindow); windowManager.setPreferences({ open: openPrefsWindow, close: closePrefsWindow }); ================================================ FILE: maintaining.md ================================================ # Maintaining ## Developing Kap Run `yarn dev` in one terminal tab to start watch mode, and in another tab, run `yarn start` to launch Kap. We strongly recommend installing an [XO editor plugin](https://github.com/sindresorhus/xo#editor-plugins) for JavaScript linting and a [Stylelint editor plugin](https://github.com/stylelint/stylelint/blob/master/docs/user-guide/integrations/editor.md) for CSS linting. Both of these support auto-fix on save. ## Releasing a new version *(You can do all the steps on github.com)* - Go to https://github.com/wulkano/kap/releases - Click `Draft a new release` - Write the new version, prefixed with `v`, in the `Tag version` field (Example: `v2.0.0`) - Leave the `Release title` field blank - Write release notes - Click `Save draft` - Change `version` [here](https://github.com/wulkano/kap/blob/main/package.json#L4) to the new version and use the version number as the commit title (Example: `2.0.0`) - CircleCI will now build the app and add the binaries to the release - When CircleCI has attached the binaries to the release, click `Edit` on the release, and then click `Publish release` ## Releasing a new beta version - Check out the `beta` branch: `git checkout beta` - Rebase from the `main` branch: `git pull --rebase origin main` - Change the `version` number in `package.json` - Amend the "Beta build customizations" commit: `git add . && git commit --amend` - Force push to the `beta` branch: `git push --force` - Tag a release with the version number in package.json and push it: `git tag -a "v2.0.0-beta.3" -m "v2.0.0-beta.3" && git push --follow-tags` - Wait for CircleCI to add the binaries to a new GitHub Releases draft - Go to the release draft that is created for you, check `This is a pre-release`, and press `Publish release` ================================================ FILE: package.json ================================================ { "name": "kap", "productName": "Kap", "version": "3.6.0", "description": "An open-source screen recorder built with web technology", "license": "MIT", "repository": "wulkano/kap", "homepage": "https://getkap.co", "author": { "name": "Wulkano", "email": "hello@wulkano.com", "url": "https://wulkano.com" }, "private": true, "main": "dist-js/index.js", "scripts": { "lint": "xo", "lint:fix": "xo --fix", "test:main": "TS_NODE_PROJECT=test/tsconfig.json ava", "test:ci": "yarn test:main --tap | tap-xunit > ~/reports/ava.xml", "test": "yarn lint && yarn test:main", "start": "tsc && run-electron .", "build-main": "tsc", "build-renderer": "next build renderer && next export renderer", "build": "yarn build-main && yarn build-renderer", "dist": "npm run build && electron-builder", "pack": "npm run build && electron-builder --dir", "postinstall": "electron-builder install-app-deps", "sentry-version": "echo \"$npm_package_name@$npm_package_version\"", "dev": "next dev renderer" }, "bundle": { "name": "Kap" }, "dependencies": { "@sentry/browser": "^6.2.2", "@sentry/electron": "^2.4.0", "@sindresorhus/to-milliseconds": "^1.2.0", "ajv": "^6.12.2", "aperture": "^6.1.2", "base64-img": "^1.0.4", "classnames": "^2.2.6", "clean-stack": "^3.0.1", "cp-file": "^9.0.0", "delay": "^5.0.0", "electron-better-ipc": "^1.1.1", "electron-log": "^4.3.2", "electron-next": "^3.1.5", "electron-notarize": "^1.1.1", "electron-store": "^7.0.2", "electron-timber": "^0.5.1", "electron-updater": "^4.3.8", "electron-util": "^0.14.2", "ensure-error": "^3.0.1", "execa": "5.0.0", "ffmpeg-static": "^4.4.1", "gifsicle": "^5.2.0", "got": "^9.6.0", "insight": "^0.10.3", "is-online": "^8.4.0", "lodash": "^4.17.21", "mac-open-with": "^1.2.3", "mac-screen-capture-permissions": "^1.1.0", "mac-windows": "^1.0.0", "macos-audio-devices": "^1.4.0", "macos-version": "^5.2.1", "make-dir": "^3.1.0", "moment": "^2.29.1", "move-file": "^2.0.0", "nearest-normal-aspect-ratio": "^1.2.1", "node-mac-app-icon": "^1.4.0", "object-hash": "^2.1.1", "p-cancelable": "^2.1.0", "p-event": "^4.2.0", "package-json": "^6.5.0", "pify": "^5.0.0", "plist": "^3.0.4", "pretty-bytes": "^5.6.0", "pretty-ms": "^7.0.1", "prop-types": "^15.7.2", "react": "^17.0.1", "react-dom": "^17.0.1", "react-linkify": "^0.2.2", "react-tooltip": "^4.2.19", "read-pkg": "^5.2.0", "semver": "^7.3.4", "string-math": "^1.2.2", "tempy": "^1.0.0", "tildify": "^2.0.0", "tmp": "^0.2.0", "unstated": "^1.2.0", "unstated-next": "^1.1.0", "yarn": "^1.22.10" }, "devDependencies": { "@babel/core": "^7.12.16", "@babel/eslint-parser": "^7.12.16", "@sindresorhus/tsconfig": "^0.7.0", "@types/ffmpeg-static": "^3.0.0", "@types/got": "9.6.12", "@types/insight": "^0.8.0", "@types/lodash": "^4.14.168", "@types/module-alias": "^2.0.0", "@types/node": "^14.11.10", "@types/object-hash": "^1.3.4", "@types/react": "^17.0.3", "@types/sinon": "^9.0.10", "@typescript-eslint/eslint-plugin": "^4.15.0", "@typescript-eslint/parser": "^4.15.0", "ava": "^3.15.0", "babel-eslint": "^10.1.0", "electron": "13.6.9", "electron-builder": "^23.3.3", "electron-builder-notarize": "^1.4.0", "eslint-config-xo": "^0.35.0", "eslint-config-xo-react": "^0.24.0", "eslint-config-xo-typescript": "^0.38.0", "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "husky": "^4.2.5", "module-alias": "^2.2.2", "next": "^10.0.8", "run-electron": "^1.0.0", "sinon": "^9.2.4", "tap-xunit": "^2.4.1", "ts-node": "^10.4.0", "type-fest": "^2.11.1", "typed-emitter": "^1.3.1", "typescript": "^4.0.3", "unique-string": "^2.0.0", "xo": "^0.38.2" }, "_moduleAliases": { "electron": "test/mocks/electron.ts" }, "ava": { "files": [ "test/**/*.ts", "test/**/*.js", "!test/helpers", "!test/mocks" ], "extensions": [ "ts" ], "verbose": true, "timeout": "5m", "failFast": true, "require": [ "ts-node/register", "module-alias/register" ] }, "xo": { "extends": "xo-react", "space": 2, "envs": [ "node", "browser" ], "rules": { "template-curly-spacing": "off", "import/no-extraneous-dependencies": "off", "import/no-unassigned-import": "off", "import/no-named-as-default-member": "off", "react/jsx-closing-tag-location": "off", "react/require-default-props": "off", "react/jsx-curly-brace-presence": "off", "react/static-property-placement": "off", "react/react-in-jsx-scope": "off", "react/boolean-prop-naming": "off", "unicorn/prefer-set-has": "off", "ava/use-test": "off", "import/extensions": "off", "node/file-extension-in-import": "off" }, "ignores": [ "dist-js", "dist", "renderer/.next", "renderer/out", "renderer/next.config.js" ], "overrides": [ { "files": [ "**/*.js", "**/*.jsx" ], "parser": "babel-eslint" }, { "files": [ "**/*.ts", "**/*.tsx" ], "extends": [ "xo-react", "xo-typescript" ], "parserOptions": { "project": [ "tsconfig.json", "renderer/tsconfig.json", "test/tsconfig.json" ] }, "rules": { "react-hooks/exhaustive-deps": "off", "@typescript-eslint/no-dynamic-delete": "off", "@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-floating-promises": "off", "@typescript-eslint/no-implicit-any-catch": "off", "@typescript-eslint/restrict-template-expressions": "off", "no-await-in-loop": "off", "react/prop-types": "off", "@typescript-eslint/no-confusing-void-expression": "off", "@typescript-eslint/indent": [ "error", 2 ] } }, { "files": [ "test/**/*.ts" ], "rules": { "@typescript-eslint/consistent-type-assertions": "off", "@typescript-eslint/member-ordering": "off", "import/no-anonymous-default-export": "off", "@typescript-eslint/no-extraneous-class": "off", "@typescript-eslint/no-empty-function": "off" } } ] }, "husky": { "hooks": { "pre-commit": "yarn lint", "pre-push": "yarn lint" } }, "build": { "appId": "com.wulkano.kap", "afterSign": "electron-builder-notarize", "protocols": { "name": "kap", "schemes": [ "kap" ] }, "files": [ "static", "dist-js/**/*", "!renderer", "renderer/out" ], "mac": { "electronUpdaterCompatibility": ">=2.16", "category": "public.app-category.productivity", "minimumSystemVersion": "10.12.0", "darkModeSupport": true, "hardenedRuntime": true, "entitlements": "./build/entitlements.mac.inherit.plist", "extendInfo": { "NSMicrophoneUsageDescription": "Kap needs access to the microphone to be able to record audio for screen recordings.", "NSCameraUsageDescription": "A Kap plugin wants to use the camera.", "NSUserNotificationAlertStyle": "alert", "CFBundleDocumentTypes": [ { "CFBundleTypeName": "Video", "CFBundleTypeRole": "Viewer", "LSHandlerRank": "Alternate", "LSItemContentTypes": [ "public.mpeg-4", "com.apple.quicktime-movie" ] } ] }, "target": { "target": "default", "arch": [ "x64", "arm64" ] } }, "dmg": { "artifactName": "${productName}-${version}-${arch}.${ext}", "iconSize": 160, "contents": [ { "x": 180, "y": 170 }, { "x": 480, "y": 170, "type": "link", "path": "/Applications" } ] } } } ================================================ FILE: renderer/components/action-bar/controls/advanced.js ================================================ import PropTypes from 'prop-types'; import React from 'react'; import css from 'styled-jsx/css'; import { SwapIcon, BackIcon, LinkIcon, DropdownArrowIcon } from '../../../vectors'; import {connect, ActionBarContainer, CropperContainer} from '../../../containers'; import { handleWidthInput, handleHeightInput, buildAspectRatioMenu, minHeight, minWidth, handleKeyboardActivation, RATIOS } from '../../../utils/inputs'; import KeyboardNumberInput from '../../keyboard-number-input'; const advancedStyles = css` .advanced { height: 64px; display: flex; flex: 1; align-items: center; padding: 0 8px; } `; const {className: keyboardInputClass, styles: keyboardInputStyles} = css.resolve` height: 32px; border: 1px solid var(--input-border-color); background: var(--input-background-color); color: var(--title-color); text-align: left; font-size: 12px; transition: border 0.12s ease-in-out; box-sizing: border-box; padding: 8px; border-radius: 4px; margin-right: 8px; width: 64px; :focus { outline: none; border: 1px solid var(--input-focus-border-color); } :hover { border-color: var(--input-hover-border-color); } `; const AdvancedControls = {}; const stopPropagation = event => event.stopPropagation(); class Left extends React.Component { state = {}; select = React.createRef(); static getDerivedStateFromProps(nextProps, previousState) { const {ratio, isResizing, setRatio} = nextProps; if (ratio !== previousState.ratio && !isResizing) { return { ratio, menu: buildAspectRatioMenu({setRatio, ratio}) }; } return null; } openMenu = () => { const {ratio} = this.props; const boundingRect = this.select.current.getBoundingClientRect(); const {top, left} = boundingRect; const selectedRatio = ratio.join(':'); const index = RATIOS.indexOf(selectedRatio); const positioningItem = index > -1 ? index : RATIOS.length; this.state.menu.popup({ x: Math.round(left), y: Math.round(top) + 6, positioningItem }); }; render() { const {advanced, toggleAdvanced, toggleRatioLock, ratioLocked, ratio = []} = this.props; return (
{ratio[0]}:{ratio[1]}
toggleRatioLock()}/>
); } } Left.propTypes = { toggleAdvanced: PropTypes.elementType.isRequired, toggleRatioLock: PropTypes.elementType.isRequired, ratioLocked: PropTypes.bool, isResizing: PropTypes.bool, ratio: PropTypes.array, setRatio: PropTypes.elementType.isRequired, advanced: PropTypes.bool }; AdvancedControls.Left = connect( [ActionBarContainer, CropperContainer], ({ratioLocked, advanced}, {ratio, isResizing}) => ({advanced, ratio, ratioLocked, isResizing}), ({toggleAdvanced, toggleRatioLock}, {setRatio}) => ({toggleAdvanced, toggleRatioLock, setRatio}) )(Left); class Right extends React.Component { constructor(props) { super(props); this.widthInput = React.createRef(); this.heightInput = React.createRef(); } onWidthChange = (event, {ignoreEmpty} = {}) => { const {bounds, height, setBounds, ratioLocked, ratio, setWidth} = this.props; const {value} = event.currentTarget; const {heightInput, widthInput} = this; setWidth(value); handleWidthInput({ bounds, height, setBounds, ratioLocked, ratio, value, widthInput: widthInput.current.getRef(), heightInput: heightInput.current.getRef(), ignoreEmpty }); }; onHeightChange = (event, {ignoreEmpty} = {}) => { const {bounds, width, setBounds, ratioLocked, ratio, setHeight} = this.props; const {value} = event.currentTarget; const {heightInput, widthInput} = this; setHeight(value); handleHeightInput({ bounds, width, setBounds, ratioLocked, ratio, value, widthInput: widthInput.current.getRef(), heightInput: heightInput.current.getRef(), ignoreEmpty }); }; onWidthBlur = event => { this.onWidthChange(event, {ignoreEmpty: false}); handleWidthInput.flush(); }; onHeightBlur = event => { this.onHeightChange(event, {ignoreEmpty: false}); handleHeightInput.flush(); }; render() { const {swapDimensions, width, height, screenWidth, screenHeight, advanced} = this.props; return (
{keyboardInputStyles}
); } } Right.propTypes = { bounds: PropTypes.object, width: PropTypes.string, height: PropTypes.string, ratio: PropTypes.array, ratioLocked: PropTypes.bool, advanced: PropTypes.bool, setBounds: PropTypes.elementType.isRequired, swapDimensions: PropTypes.elementType.isRequired, setWidth: PropTypes.elementType.isRequired, setHeight: PropTypes.elementType.isRequired, screenWidth: PropTypes.number, screenHeight: PropTypes.number }; AdvancedControls.Right = connect( [CropperContainer, ActionBarContainer], ( {x, y, ratio, width, height, screenWidth, screenHeight}, {cropperWidth, cropperHeight, ratioLocked, advanced} ) => ({ screenHeight, screenWidth, bounds: {x, y, width, height}, width: cropperWidth, height: cropperHeight, ratio, ratioLocked, advanced }), ( {setBounds, swapDimensions}, {setWidth, setHeight} ) => ({ setBounds, swapDimensions, setWidth, setHeight }) )(Right); export default AdvancedControls; ================================================ FILE: renderer/components/action-bar/controls/main.js ================================================ import electron from 'electron'; import PropTypes from 'prop-types'; import React from 'react'; import css from 'styled-jsx/css'; import IconMenu from '../../icon-menu'; import { MoreIcon, CropIcon, ApplicationsIcon, FullscreenIcon, ExitFullscreenIcon } from '../../../vectors'; import {connect, ActionBarContainer, CropperContainer} from '../../../containers'; const mainStyle = css` .main { height: 64px; display: flex; flex: 1; align-items: center; } `; const MainControls = {}; const remote = electron.remote || false; let menu; const buildMenu = async ({selectedApp}) => { const {buildWindowsMenu} = remote.require('./utils/windows'); menu = await buildWindowsMenu(selectedApp); }; class Left extends React.Component { state = {}; static getDerivedStateFromProps(nextProps, previousState) { const {selectedApp} = nextProps; if (selectedApp !== previousState.selectedApp) { buildMenu({selectedApp}); return {selectedApp}; } return null; } render() { const {toggleAdvanced, selectedApp, advanced} = this.props; return (
); } } Left.propTypes = { toggleAdvanced: PropTypes.elementType.isRequired, selectedApp: PropTypes.string, advanced: PropTypes.bool }; MainControls.Left = connect( [CropperContainer, ActionBarContainer], ({selectedApp}, {advanced}) => ({selectedApp, advanced}), (_, {toggleAdvanced}) => ({toggleAdvanced}) )(Left); class Right extends React.Component { onCogMenuClick = async () => { const cogMenu = await electron.remote.require('./menus/cog').getCogMenu(); cogMenu.popup(); }; render() { const {enterFullscreen, exitFullscreen, isFullscreen, advanced} = this.props; return (
{ isFullscreen ? : }
); } } Right.propTypes = { enterFullscreen: PropTypes.elementType.isRequired, exitFullscreen: PropTypes.elementType.isRequired, isFullscreen: PropTypes.bool, advanced: PropTypes.bool }; MainControls.Right = connect( [CropperContainer, ActionBarContainer], ({isFullscreen}, {advanced}) => ({isFullscreen, advanced}), ({enterFullscreen, exitFullscreen}) => ({enterFullscreen, exitFullscreen}) )(Right); export default MainControls; ================================================ FILE: renderer/components/action-bar/index.js ================================================ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; import {connect, CropperContainer, ActionBarContainer} from '../../containers'; import MainControls from './controls/main'; import AdvancedControls from './controls/advanced'; import RecordButton from './record-button'; const TRANSITION_DURATION = 0.2; class ActionBar extends React.Component { static defaultProps = { cropperWidth: 0, cropperHeight: 0, width: 0, height: 0, x: 0, y: 0 }; render() { const { startMoving, x, y, width, height, hidden, advanced, isMoving, cropperWidth, cropperHeight } = this.props; const className = classNames('action-bar', {moving: isMoving, hidden, 'is-advanced': advanced}); return (
); } } ActionBar.propTypes = { startMoving: PropTypes.elementType.isRequired, x: PropTypes.number, y: PropTypes.number, width: PropTypes.number, height: PropTypes.number, hidden: PropTypes.bool, advanced: PropTypes.bool, isMoving: PropTypes.bool, cropperWidth: PropTypes.number, cropperHeight: PropTypes.number }; export default connect( [ActionBarContainer, CropperContainer], ({advanced, isMoving, width, height, x, y}, {willStartRecording, isPicking, isResizing, width: cropperWidth, isActive, height: cropperHeight, isMoving: cropperMoving}) => ({ advanced, width, height, x, y, isMoving, hidden: !isActive || cropperMoving || isResizing || isPicking || willStartRecording, cropperWidth, cropperHeight }), ({startMoving}) => ({startMoving}) )(ActionBar); ================================================ FILE: renderer/components/action-bar/record-button.js ================================================ import electron from 'electron'; import React, {useState, useEffect} from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import {connect, CropperContainer} from '../../containers'; import {handleKeyboardActivation} from '../../utils/inputs'; const getMediaNode = async deviceId => new Promise((resolve, reject) => { navigator.getUserMedia({ audio: {deviceId} }, stream => { const audioContext = new AudioContext(); const analyser = audioContext.createAnalyser(); const microphone = audioContext.createMediaStreamSource(stream); const javascriptNode = audioContext.createScriptProcessor(2048, 1, 1); analyser.smoothingTimeConstant = 0.8; analyser.fftSize = 1024; microphone.connect(analyser); analyser.connect(javascriptNode); javascriptNode.connect(audioContext.destination); resolve({javascriptNode, analyser}); }, reject); }); const RecordButton = ({ cropperExists, x, y, width, height, screenWidth, screenHeight, displayId, willStartRecording, recordAudio, audioInputDeviceId }) => { const [showFirstRipple, setShowFirstRipple] = useState(false); const [showSecondRipple, setShowSecondRipple] = useState(false); const [shouldStop, setShouldStop] = useState(false); useEffect(() => { let node; const connectToDevice = async () => { try { const {javascriptNode, analyser} = await getMediaNode(audioInputDeviceId); javascriptNode.onaudioprocess = () => { const array = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(array); // eslint-disable-next-line unicorn/no-array-reduce const avg = array.reduce((p, c) => p + c) / array.length; if (avg >= 36) { setShowFirstRipple(true); setShowSecondRipple(true); setShouldStop(false); } else { setShouldStop(true); } }; node = javascriptNode; } catch (error) { console.error('An error occurred when trying to get audio levels:', error); } }; if (recordAudio && audioInputDeviceId) { connectToDevice(); } return () => { if (node && typeof node.disconnect === 'function') { node.disconnect(); } }; }, [recordAudio, audioInputDeviceId]); const shouldFirstStop = () => { if (shouldStop) { setShowFirstRipple(false); } }; const shouldSecondStop = () => { if (shouldStop) { setShowSecondRipple(false); } }; const startRecording = event => { event.stopPropagation(); if (cropperExists) { const {remote} = electron; const {startRecording} = remote.require('./aperture'); willStartRecording(); startRecording({ cropperBounds: { x, y, width, height }, screenBounds: { width: screenWidth, height: screenHeight }, displayId }); } }; return (
{!cropperExists &&
}
{showFirstRipple &&
} {showSecondRipple &&
}
); }; RecordButton.propTypes = { cropperExists: PropTypes.bool, x: PropTypes.number, y: PropTypes.number, width: PropTypes.number, height: PropTypes.number, screenWidth: PropTypes.number, screenHeight: PropTypes.number, displayId: PropTypes.number, willStartRecording: PropTypes.elementType, recordAudio: PropTypes.bool, audioInputDeviceId: PropTypes.string }; export default connect( [CropperContainer], ({x, y, width, height, screenWidth, screenHeight, displayId, recordAudio, audioInputDeviceId}) => ({x, y, width, height, screenWidth, screenHeight, displayId, recordAudio, audioInputDeviceId}), ({willStartRecording}) => ({willStartRecording}) )(RecordButton); ================================================ FILE: renderer/components/config/index.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; import {connect, ConfigContainer} from '../../containers'; import Tab from './tab'; class Config extends React.Component { render() { const { validators, values, onChange, selectedTab, selectTab, closeWindow, openConfig, viewOnGithub, serviceTitle } = this.props; if (!validators) { return null; } return (
{ validators.length > 1 && ( ) }
{ validators.map(validator => { return (
); }) }
); } } Config.propTypes = { validators: PropTypes.arrayOf(PropTypes.elementType), values: PropTypes.object, onChange: PropTypes.elementType.isRequired, selectedTab: PropTypes.number, selectTab: PropTypes.elementType.isRequired, closeWindow: PropTypes.elementType.isRequired, openConfig: PropTypes.elementType.isRequired, viewOnGithub: PropTypes.elementType.isRequired, serviceTitle: PropTypes.string }; export default connect( [ConfigContainer], ({validators, values, selectedTab, serviceTitle}) => ({validators, values, selectedTab, serviceTitle}), ({onChange, selectTab, closeWindow, openConfig, viewOnGithub}) => ({onChange, selectTab, closeWindow, openConfig, viewOnGithub}) )(Config); ================================================ FILE: renderer/components/config/tab.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; import Linkify from 'react-linkify'; import Item, {Link} from '../preferences/item'; import Select from '../preferences/item/select'; import Switch from '../preferences/item/switch'; import ColorPicker from '../preferences/item/color-picker'; import {OpenOnGithubIcon, OpenConfigIcon} from '../../vectors'; import ShortcutInput from '../preferences/shortcut-input'; const horizontalTypes = [ 'boolean', 'hexColor' ]; const ConfigInput = ({name, type, schema, value, onChange, hasErrors}) => { if (type === 'keyboardShortcut') { return (
onChange(name, value)}/>
); } if (type === 'hexColor') { return onChange(name, value)}/>; } if (type === 'select') { const options = schema.enum.map(value => ({label: value, value})); if (!options.some(option => option.value === value)) { const newValue = options[0] && options[0].value; onChange(name, newValue); return onChange(name, value)}/>; } if (type === 'boolean') { return onChange(name, !value)}/>; } const className = hasErrors ? 'has-errors' : ''; const handleChange = event => { const value = type === 'number' ? Number.parseFloat(event.target.value) : event.currentTarget.value; onChange(name, value); }; return (
); }; ConfigInput.propTypes = { name: PropTypes.string, type: PropTypes.string, schema: PropTypes.object, value: PropTypes.oneOfType([ PropTypes.string, PropTypes.bool, PropTypes.number ]), onChange: PropTypes.elementType.isRequired, hasErrors: PropTypes.bool }; class Tab extends React.Component { render() { const {validator, values, onChange, openConfig, viewOnGithub, serviceTitle} = this.props; const {config, errors, description} = validator; const allErrors = errors || []; return (
{ description && (
{description}
) } { [...Object.keys(config)].map(key => { const schema = config[key]; const type = schema.customType || (schema.enum ? 'select' : schema.type); const itemErrors = allErrors .filter(({dataPath}) => dataPath.endsWith(key)) .map(({message}) => `This ${message}`); return ( 0} name={key} type={type} schema={schema} value={values[key]} onChange={onChange} /> ); }) } { !serviceTitle && (
) }
); } } Tab.propTypes = { validator: PropTypes.elementType, values: PropTypes.object, onChange: PropTypes.elementType.isRequired, openConfig: PropTypes.elementType.isRequired, viewOnGithub: PropTypes.elementType.isRequired, serviceTitle: PropTypes.string }; export default Tab; ================================================ FILE: renderer/components/cropper/cursor.js ================================================ import electron from 'electron'; import PropTypes from 'prop-types'; import React from 'react'; import classNames from 'classnames'; import {connect, CursorContainer, CropperContainer} from '../../containers'; class Cursor extends React.Component { remote = electron.remote || false; render() { if (!this.remote) { return null; } const { cursorY, cursorX, width, height, screenWidth, screenHeight } = this.props; const className = classNames('dimensions', { flipY: screenHeight - cursorY < 35, flipX: screenWidth - cursorX < 40 }); return (
{width}
{height}
); } } Cursor.propTypes = { cursorX: PropTypes.number, cursorY: PropTypes.number, width: PropTypes.number, height: PropTypes.number, screenWidth: PropTypes.number, screenHeight: PropTypes.number }; export default connect( [CursorContainer, CropperContainer], ({cursorX, cursorY}, {screenWidth, screenHeight}) => ({cursorX, cursorY, screenWidth, screenHeight}) )(Cursor); ================================================ FILE: renderer/components/cropper/handles.js ================================================ import React from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import {connect, CropperContainer, ActionBarContainer} from '../../containers'; class Handle extends React.Component { static defaultProps = { size: 8, top: false, bottom: false, left: false, right: false, ratioLocked: false }; render() { const { size, top, bottom, right, left, onClick, ratioLocked } = this.props; const className = classNames('handle', { 'handle-top': top, 'handle-bottom': bottom, 'handle-right': right, 'handle-left': left, 'place-on-top': top + bottom + left + right === 2, hide: ratioLocked && top + bottom + left + right === 1 }); return (
onClick(this.props)}>
); } } Handle.propTypes = { size: PropTypes.number, top: PropTypes.bool, bottom: PropTypes.bool, left: PropTypes.bool, right: PropTypes.bool, onClick: PropTypes.elementType.isRequired, ratioLocked: PropTypes.bool }; class Handles extends React.Component { static defaultProps = { ratioLocked: false, width: 0, height: 0 }; render() { const { startResizing, showHandles, ratioLocked, width, height, isActive, willStartRecording } = this.props; if (width + height === 0) { return null; } const show = !willStartRecording && isActive && showHandles; return (
{ show && [...(Array.from({length: 8}).keys())].map( i => ( ) ) } { this.props.children }
); } } Handles.propTypes = { isActive: PropTypes.bool, width: PropTypes.number, height: PropTypes.number, startResizing: PropTypes.elementType.isRequired, showHandles: PropTypes.bool, ratioLocked: PropTypes.bool, willStartRecording: PropTypes.bool, children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node ]).isRequired }; export default connect( [CropperContainer, ActionBarContainer], ({showHandles, width, height, isActive, willStartRecording}, {ratioLocked}) => ({showHandles, width, height, isActive, ratioLocked, willStartRecording}), ({startResizing}) => ({startResizing}) )(Handles); export const getResizingCursor = ({top, bottom, right, left}) => { if ((top || bottom) && !left && !right) { return 'cursor: ns-resize;'; } if ((left || right) && !top && !bottom) { return 'cursor: ew-resize;'; } if ((top && left) || (bottom && right)) { return 'cursor: nwse-resize;'; } if ((top && right) || (bottom && left)) { return 'cursor: nesw-resize;'; } }; ================================================ FILE: renderer/components/cropper/index.js ================================================ import PropTypes from 'prop-types'; import React from 'react'; import {connect, CropperContainer} from '../../containers'; import Handles from './handles'; import Cursor from './cursor'; class Cropper extends React.Component { render() { const {startMoving, width, height, isResizing} = this.props; return (
{ isResizing && } ); } } Cropper.propTypes = { startMoving: PropTypes.elementType.isRequired, width: PropTypes.number, height: PropTypes.number, isResizing: PropTypes.bool }; export default connect( [CropperContainer], ({width, height, isResizing}) => ({width, height, isResizing}), ({startMoving}) => ({startMoving}) )(Cropper); ================================================ FILE: renderer/components/cropper/overlay.js ================================================ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; import { connect, CursorContainer, CropperContainer, ActionBarContainer } from '../../containers'; import {getResizingCursor} from './handles'; class Overlay extends React.Component { static defaultProps = { x: 0, y: 0, width: 0, height: 0, isReady: false }; render() { const { onMouseUp, setCursor, startPicking, x, y, width, height, isMoving, isResizing, currentHandle, isActive, isReady, screenWidth, screenHeight, isRecording, selectedApp } = this.props; const contentClassName = classNames('content', {'not-ready': !isReady}); const className = classNames('overlay', { recording: isRecording, picking: !isRecording && !isResizing && !isMoving, 'no-transition': isResizing || isMoving || !isActive || Boolean(selectedApp) }); return (
{ isReady && this.props.children }
); } } Overlay.propTypes = { onMouseUp: PropTypes.elementType.isRequired, setCursor: PropTypes.elementType.isRequired, startPicking: PropTypes.elementType.isRequired, x: PropTypes.number, y: PropTypes.number, selectedApp: PropTypes.string, width: PropTypes.number, height: PropTypes.number, isMoving: PropTypes.bool, isResizing: PropTypes.bool, currentHandle: PropTypes.object, children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node ]).isRequired, isActive: PropTypes.bool, isReady: PropTypes.bool, screenWidth: PropTypes.number, screenHeight: PropTypes.number, isRecording: PropTypes.bool }; export default connect( [CropperContainer, ActionBarContainer, CursorContainer], ({x, y, width, height, isMoving, isResizing, currentHandle, screenWidth, screenHeight, isReady, isActive, isRecording, selectedApp}, actionBar) => ({ x, y, width, height, isResizing, currentHandle, screenWidth, screenHeight, isReady, isActive, isRecording, isMoving: isMoving || actionBar.isMoving, selectedApp }), ({stopMoving, stopResizing, stopPicking, startPicking}, actionBar, {setCursor}) => ({ onMouseUp: () => { stopMoving(); stopResizing(); stopPicking(); actionBar.stopMoving(); }, setCursor, startPicking }) )(Overlay); ================================================ FILE: renderer/components/dialog/actions.js ================================================ import React, {useState, useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; const Actions = ({buttons, performAction, defaultId}) => { const [activeButton, setActiveButton] = useState(); const defaultButton = useRef(null); useEffect(() => { setActiveButton(); if (defaultButton.current) { defaultButton.current.focus(); } }, [buttons]); const action = async index => { setActiveButton(index); performAction(index); }; return (
{ buttons.map((button, index) => ( )) }
); }; Actions.propTypes = { performAction: PropTypes.elementType, defaultId: PropTypes.number, buttons: PropTypes.arrayOf(PropTypes.object) }; export default Actions; ================================================ FILE: renderer/components/dialog/body.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; const Body = ({title, message, detail}) => { return (

{title}

{ detail.split('\n').map(text => ( {text} )) }
{message &&

{message}

}
); }; Body.propTypes = { title: PropTypes.string, message: PropTypes.string, detail: PropTypes.string }; export default Body; ================================================ FILE: renderer/components/dialog/icon.js ================================================ import React from 'react'; const Icon = () => { return (
); }; export default Icon; ================================================ FILE: renderer/components/editor/controls/left.tsx ================================================ import VideoControlsContainer from '../video-controls-container'; import VideoTimeContainer from '../video-time-container'; import {PlayIcon, PauseIcon} from '../../../vectors'; import formatTime from '../../../utils/format-time'; const LeftControls = () => { const {isPaused, play, pause} = VideoControlsContainer.useContainer(); const {currentTime} = VideoTimeContainer.useContainer(); return (
{ isPaused ? : }
{formatTime(currentTime, {showMilliseconds: false})}
); }; export default LeftControls; ================================================ FILE: renderer/components/editor/controls/play-bar.tsx ================================================ import VideoTimeContainer from '../video-time-container'; import {useState, useRef} from 'react'; import VideoControlsContainer from '../video-controls-container'; import Preview from './preview'; const PlayBar = () => { const [resizing, setResizing] = useState(false); const [hoverTime, setHoverTime] = useState(0); const progress = useRef(); const {play, pause} = VideoControlsContainer.useContainer(); const { currentTime, duration, startTime, endTime, updateTime, updateStartTime, updateEndTime } = VideoTimeContainer.useContainer(); const total = endTime - startTime; const current = currentTime - startTime; const getTimeFromEvent = event => { const cursorX = event.clientX; const {x, width} = progress.current.getBoundingClientRect(); const percent = (cursorX - x) / width; const time = startTime + ((endTime - startTime) * percent); return Math.max(0, time); }; const seek = event => { const time = getTimeFromEvent(event); if (startTime <= time && time <= endTime) { updateTime(time); } }; const updatePreview = event => { setHoverTime(getTimeFromEvent(event)); }; const startResizing = () => { setResizing(true); pause(); }; const stopResizing = () => { setResizing(false); play(); }; const setStartTime = event => { updateStartTime(Number.parseFloat(event.target.value)); }; const setEndTime = event => { updateEndTime(Number.parseFloat(event.target.value)); }; const previewTime = resizing ? currentTime : hoverTime; const previewLabelTime = resizing ? currentTime : (startTime <= hoverTime && hoverTime <= endTime ? hoverTime - startTime : hoverTime); const previewDuration = resizing ? total : (startTime <= hoverTime && hoverTime <= endTime ? total : undefined); return (
); }; export default PlayBar; // Import PropTypes from 'prop-types'; // import React from 'react'; // import classNames from 'classnames'; // import {connect, VideoContainer} from '../../../containers'; // import Preview from './preview'; // class PlayBar extends React.Component { // state = { // hoverTime: 0 // }; // progress = React.createRef(); // getTimeFromEvent = event => { // const {startTime, endTime} = this.props; // const cursorX = event.clientX; // const {x, width} = this.progress.current.getBoundingClientRect(); // const percent = (cursorX - x) / width; // const time = startTime + ((endTime - startTime) * percent); // return Math.max(0, time); // } // seek = event => { // const {startTime, endTime, seek} = this.props; // const time = this.getTimeFromEvent(event); // if (startTime <= time && time <= endTime) { // seek(time); // } // } // updatePreview = event => { // const time = this.getTimeFromEvent(event); // this.setState({hoverTime: time}); // } // startResizing = () => { // const {pause} = this.props; // this.setState({resizing: true}); // pause(); // } // stopResizing = () => { // const {play} = this.props; // this.setState({resizing: false}); // play(); // } // setStartTime = event => this.props.setStartTime(Number.parseFloat(event.target.value)) // setEndTime = event => this.props.setEndTime(Number.parseFloat(event.target.value)) // render() { // const {currentTime = 0, duration, startTime, endTime, hover, src} = this.props; // if (!src) { // return null; // } // const {hoverTime, resizing} = this.state; // const total = endTime - startTime; // const current = currentTime - startTime; // const previewTime = resizing ? currentTime : hoverTime; // const previewLabelTime = resizing ? currentTime : (startTime <= hoverTime && hoverTime <= endTime ? hoverTime - startTime : hoverTime); // const previewDuration = resizing ? total : (startTime <= hoverTime && hoverTime <= endTime ? total : undefined); // const className = classNames('progress-bar-container', {hover}); // return ( //
//
//
// //
// //
// // //
//
// //
// ); // } // } // PlayBar.propTypes = { // startTime: PropTypes.number, // endTime: PropTypes.number, // seek: PropTypes.elementType, // currentTime: PropTypes.number, // duration: PropTypes.number, // src: PropTypes.string, // setStartTime: PropTypes.elementType, // setEndTime: PropTypes.elementType, // pause: PropTypes.elementType, // play: PropTypes.elementType, // hover: PropTypes.bool // }; // export default connect( // [VideoContainer], // ({currentTime, duration, startTime, endTime, src}) => ({currentTime, duration, startTime, endTime, src}), // ({seek, setStartTime, setEndTime, pause, play}) => ({seek, setStartTime, setEndTime, pause, play}) // )(PlayBar); ================================================ FILE: renderer/components/editor/controls/preview.tsx ================================================ import formatTime from '../../../utils/format-time'; import {useRef, useEffect} from 'react'; import useEditorWindowState from 'hooks/editor/use-editor-window-state'; type Props = { time: number; labelTime: number; duration: number; hidePreview: boolean; }; const Preview = ({time, labelTime, duration, hidePreview}: Props) => { const videoRef = useRef(); const {filePath} = useEditorWindowState(); const src = `file://${filePath}`; useEffect(() => { if (!hidePreview) { videoRef.current.currentTime = time; } }, [time, hidePreview]); return (
{ event.stopPropagation(); }} >
); }; export default Preview; // Import PropTypes from 'prop-types'; // import React from 'react'; // import formatTime from '../../../utils/format-time'; // class Preview extends React.Component { // constructor(props) { // super(props); // this.videoRef = React.createRef(); // } // shouldComponentUpdate(nextProps) { // return nextProps.time !== this.props.time || nextProps.hidePreview !== this.props.hidePreview; // } // componentDidUpdate(previousProps) { // if (previousProps.time !== this.props.time) { // this.videoRef.current.currentTime = this.props.time; // } // } // render() { // const {labelTime, duration, hidePreview, src} = this.props; // return ( //
event.stopPropagation()}> //
// ); // } // } // Preview.propTypes = { // time: PropTypes.number, // labelTime: PropTypes.number, // duration: PropTypes.number, // hidePreview: PropTypes.bool, // src: PropTypes.string // }; // export default Preview; ================================================ FILE: renderer/components/editor/controls/right.tsx ================================================ import {VolumeHighIcon, VolumeOffIcon} from '../../../vectors'; import VideoControlsContainer from '../video-controls-container'; import VideoMetadataContainer from '../video-metadata-container'; import formatTime from '../../../utils/format-time'; const RightControls = () => { const {isMuted, mute, unmute} = VideoControlsContainer.useContainer(); const {hasAudio, duration} = VideoMetadataContainer.useContainer(); // FIXME const format = 'mp4'; const canUnmute = !['gif', 'apng'].includes(format) && hasAudio; const unmuteColor = canUnmute ? '#fff' : 'rgba(255, 255, 255, 0.40)'; return (
{formatTime(duration)}
{ isMuted || !hasAudio ? : }
); }; export default RightControls; ================================================ FILE: renderer/components/editor/conversion/conversion-details.tsx ================================================ import {UseConversionState} from 'hooks/editor/use-conversion'; const ConversionDetails = ({conversion, showInFolder}: {conversion: UseConversionState; showInFolder: () => void}) => { const message = conversion?.message; const title = conversion?.titleWithFormat; const description = conversion?.description; const size = conversion?.fileSize; return (
{message}
{title}
{description}
{size}
); }; export default ConversionDetails; ================================================ FILE: renderer/components/editor/conversion/index.tsx ================================================ import {ExportStatus} from 'common/types'; import useConversion from 'hooks/editor/use-conversion'; import useConversionIdContext from 'hooks/editor/use-conversion-id'; import {useConfirmation} from 'hooks/use-confirmation'; import {useMemo} from 'react'; import {useKeyboardAction} from '../../../hooks/use-keyboard-action'; import ConversionDetails from './conversion-details'; import TitleBar from './title-bar'; import VideoPreview from './video-preview'; const dialogOptions = { message: 'Are you sure you want to discard this conversion?', detail: 'Any progress will be lost.', confirmButtonText: 'Discard' }; const EditorConversionView = ({conversionId}: {conversionId: string}) => { const {setConversionId} = useConversionIdContext(); const conversion = useConversion(conversionId); const inProgress = conversion.state?.status === ExportStatus.inProgress; const cancel = () => { if (inProgress) { conversion.cancel(); } }; const safeCancel = useConfirmation(cancel, dialogOptions); const cancelAndGoBack = () => { cancel(); setConversionId(''); }; const finalCancel = useMemo(() => inProgress ? safeCancel : () => { /* do nothing */ }, [inProgress]); useKeyboardAction('Escape', finalCancel); const showInFolder = () => conversion.showInFolder(); return (
{ conversion.copy(); }} retry={() => { conversion.retry(); }} showInFolder={showInFolder}/>
); }; export default EditorConversionView; ================================================ FILE: renderer/components/editor/conversion/title-bar.tsx ================================================ import TrafficLights from 'components/traffic-lights'; import {BackPlainIcon, MoreIcon} from 'vectors'; import {UseConversionState} from 'hooks/editor/use-conversion'; import {flags} from '../../../common/flags'; import {MenuItemConstructorOptions, remote} from 'electron'; import {ExportStatus} from '../../../common/types'; import {useMemo} from 'react'; import {template} from 'lodash'; import IconMenu from '../../icon-menu'; const TitleBar = ({conversion, cancel, copy, retry, showInFolder}: {conversion: UseConversionState; cancel: () => any; copy: () => any; retry: () => any; showInFolder: () => void}) => { const {api} = require('electron-util'); const shouldClose = async () => { if (conversion.status === ExportStatus.inProgress && !flags.get('backgroundEditorConversion')) { await api.dialog.showMessageBox(remote.getCurrentWindow(), { type: 'info', message: 'Your export will continue in the background. You can access it through the Export History window.', buttons: ['Ok'], defaultId: 0 }); flags.set('backgroundEditorConversion', true); } return true; }; const menuTemplate = useMemo(() => { const template: MenuItemConstructorOptions[] = []; if (conversion?.canCopy) { template.push({ label: 'Copy', click: () => copy() }, { type: 'separator' }); } if (conversion?.status === ExportStatus.completed) { template.push({ label: 'Show in Finder', click: () => showInFolder() }); } return template; }, [conversion?.canCopy, conversion?.status]); const canRetry = [ExportStatus.canceled, ExportStatus.failed].includes(conversion?.status); return (
{canRetry &&
Retry
} { menuTemplate.length > 0 && (
) }
); }; export default TitleBar; ================================================ FILE: renderer/components/editor/conversion/video-preview.tsx ================================================ import {CancelIcon, SpinnerIcon} from 'vectors'; import {UseConversion, UseConversionState} from 'hooks/editor/use-conversion'; import {ExportStatus} from 'common/types'; import useEditorWindowState from 'hooks/editor/use-editor-window-state'; import useConversionIdContext from 'hooks/editor/use-conversion-id'; import {flags} from '../../../common/flags'; import ReactTooltip from 'react-tooltip'; import {useEffect, useRef, useState} from 'react'; import classNames from 'classnames'; const VideoPreview = ({conversion, cancel, showInFolder}: {conversion: UseConversionState; cancel: () => any; showInFolder: () => any}) => { const {conversionId} = useConversionIdContext(); const {filePath} = useEditorWindowState(); const [tooltipShowing, setTooltipShowing] = useState(!flags.get('editorDragTooltip')); const tooltipRef = useRef(); const src = `file://${filePath}`; const percentage = conversion?.progress ?? 0; const done = conversion && (conversion?.status !== ExportStatus.inProgress); const onDragStart = (event: any) => { event.preventDefault(); // Has to be the electron one for this const {ipcRenderer} = require('electron'); ipcRenderer.send('drag-export', conversionId); }; useEffect(() => { if (!done) { return; } if (tooltipShowing) { ReactTooltip.show(tooltipRef.current); } else { ReactTooltip.hide(tooltipRef.current); } }, [tooltipRef.current, tooltipShowing, done]); const onTooltipClick = event => { event.stopPropagation(); setTooltipShowing(false); }; const onTooltipHide = () => { flags.set('editorDragTooltip', true); }; return (
{ done && conversion?.canPreviewExport ? :
); }; const IndeterminateSpinner = () => (
); const ProgressCircle = ({percent}: {percent: number}) => { const circumference = 12 * 2 * Math.PI; const offset = circumference * (1 - percent); return ( ); }; export default VideoPreview; ================================================ FILE: renderer/components/editor/editor-preview.tsx ================================================ import TrafficLights from '../traffic-lights'; import VideoPlayer from './video-player'; import Options from './options'; import useEditorWindowState from 'hooks/editor/use-editor-window-state'; const EditorPreview = () => { const {title = 'Editor'} = useEditorWindowState(); return (
{title}
); }; export default EditorPreview; ================================================ FILE: renderer/components/editor/index.tsx ================================================ import useConversionIdContext from 'hooks/editor/use-conversion-id'; import useEditorWindowState from 'hooks/editor/use-editor-window-state'; import {useEditorWindowSizeEffect} from 'hooks/editor/use-window-size'; import {useEffect, useState} from 'react'; import EditorConversionView from './conversion'; import EditorPreview from './editor-preview'; import classNames from 'classnames'; const Editor = () => { const {conversionId, setConversionId} = useConversionIdContext(); const state = useEditorWindowState(); const [isConversionPreviewState, setIsConversionPreviewState] = useState(false); useEffect(() => { if (state.conversionId && !conversionId) { setConversionId(state.conversionId); } }, [state.conversionId]); useEditorWindowSizeEffect(isConversionPreviewState); const isTransitioning = Boolean(conversionId) !== isConversionPreviewState; const className = classNames('container', { transitioning: isTransitioning }); const onTransitionEnd = () => { setIsConversionPreviewState(Boolean(conversionId)); }; return (
{ isConversionPreviewState ? : }
); }; export default Editor; ================================================ FILE: renderer/components/editor/options/index.tsx ================================================ import LeftOptions from './left'; import RightOptions from './right'; const Options = () => { return (
); }; export default Options; ================================================ FILE: renderer/components/editor/options/left.tsx ================================================ import css from 'styled-jsx/css'; import KeyboardNumberInput from '../../keyboard-number-input'; import Slider from './slider'; import OptionsContainer from '../options-container'; import {useState, useEffect, useMemo} from 'react'; import * as stringMath from 'string-math'; import VideoMetadataContainer from '../video-metadata-container'; import {shake} from '../../../utils/inputs'; import Select, {Separator} from './select'; const percentValues = [100, 75, 50, 33, 25, 20, 10]; const {className: keyboardInputClass, styles: keyboardInputStyles} = css.resolve` height: 24px; background: rgba(255, 255, 255, 0.1); text-align: center; font-size: 12px; box-sizing: border-box; border: none; padding: 4px; border-bottom-left-radius: 4px; border-top-left-radius: 4px; width: 48px; color: white; box-shadow: inset 0px 1px 0px 0px rgba(255, 255, 255, 0.04), 0px 1px 2px 0px rgba(0, 0, 0, 0.2); input + input { border-bottom-left-radius: 0; border-top-left-radius: 0; border-bottom-right-radius: 4px; border-top-right-radius: 4px; margin-left: 1px; margin-right: 16px; } :focus, :hover { outline: none; background: hsla(0, 0%, 100%, 0.2); } `; const LeftOptions = () => { const {width, height, setDimensions, fps, updateFps, originalFps} = OptionsContainer.useContainer(); const metadata = VideoMetadataContainer.useContainer(); const [widthValue, setWidthValue] = useState(); const [heightValue, setHeightValue] = useState(); const onChange = (event, {ignoreEmpty = true}: {ignoreEmpty?: boolean} = {}) => { if (!ignoreEmpty) { onBlur(event); return; } const {currentTarget: {name, value}} = event; if (name === 'width') { setWidthValue(value); } else { setHeightValue(value); } }; const onBlur = event => { const {currentTarget} = event; const {name} = currentTarget; let value: number; try { value = stringMath(currentTarget.value); } catch {} // Fallback to last valid const updates = {width, height}; if (value) { value = Math.round(value); const ratio = metadata.width / metadata.height; if (name === 'width') { const min = Math.max(1, Math.ceil(ratio)); if (value < min) { shake(currentTarget, {className: 'shake-left'}); updates.width = min; } else if (value > metadata.width) { shake(currentTarget, {className: 'shake-left'}); updates.width = metadata.width; } else { updates.width = value; } updates.height = Math[ratio > 1 ? 'ceil' : 'floor'](updates.width / ratio); } else { const min = Math.max(1, Math.ceil(1 / ratio)); if (value < min) { shake(currentTarget, {className: 'shake-right'}); updates.height = min; } else if (value > metadata.height) { shake(currentTarget, {className: 'shake-right'}); updates.height = metadata.height; } else { updates.height = value; } updates.width = Math[ratio > 1 ? 'floor' : 'ceil'](updates.height * ratio); } } else if (name === 'width') { shake(currentTarget, {className: 'shake-left'}); } else { shake(currentTarget, {className: 'shake-right'}); } setDimensions(updates); setWidthValue(updates.width.toString()); setHeightValue(updates.height.toString()); }; useEffect(() => { if (width && height) { setWidthValue(width.toString()); setHeightValue(height.toString()); } }, [width, height]); const percentOptions = useMemo(() => { const ratio = metadata.width / metadata.height; const options = percentValues.map(percent => { const adjustedWidth = Math.round(metadata.width * (percent / 100)); const adjustedHeight = Math[ratio > 1 ? 'ceil' : 'floor'](adjustedWidth / ratio); return { label: `${adjustedWidth} x ${adjustedHeight} (${percent === 100 ? 'Original' : `${percent}%`})`, value: {width: adjustedWidth, height: adjustedHeight}, checked: width === adjustedWidth }; }); if (options.every(opt => !opt.checked)) { return [ { label: 'Custom', value: {width, height}, checked: true }, { separator: true }, ...options ]; } return options; }, [metadata, width, height]); const selectPercentage = updates => { setDimensions(updates); setWidthValue(updates.width.toString()); setHeightValue(updates.height.toString()); }; const percentLabel = `${Math.round((width / metadata.width) * 100)}%`; return (
Size
; }; const PluginsSelect = () => { const {menuOptions, label, onChange} = useSharePlugins(); return
); }; const ConvertButton = () => { const {startConversion} = useConversionIdContext(); const options = OptionsContainer.useContainer(); const {filePath} = useEditorWindowState(); const {startTime, endTime} = VideoTimeContainer.useContainer(); const {isMuted} = VideoControlsContainer.useContainer(); const {updatePluginUsage} = useEditorOptions(); const onClick = () => { const shouldCrop = true; startConversion({ filePath, conversionOptions: { width: options.width, height: options.height, startTime, endTime, fps: options.fps, shouldMute: isMuted, shouldCrop, editService: options.editPlugin ? { pluginName: options.editPlugin.pluginName, serviceTitle: options.editPlugin.title } : undefined }, format: options.format, plugins: { share: options.sharePlugin } }); updatePluginUsage({ format: options.format, plugin: options.sharePlugin.pluginName }); }; return ( ); }; const RightOptions = () => { return (
); }; export default RightOptions; // Import electron from 'electron'; // import React from 'react'; // import PropTypes from 'prop-types'; // import {connect, EditorContainer} from '../../../containers'; // import Select from './select'; // import {GearIcon} from '../../../vectors'; // class RightOptions extends React.Component { // render() { // const { // options, // format, // plugin, // selectFormat, // selectPlugin, // startExport, // openWithApp, // selectOpenWithApp, // selectEditPlugin, // editOptions, // editPlugin, // openEditPluginConfig // } = this.props; // const formatOptions = options ? options.map(({format, prettyFormat}) => ({value: format, label: prettyFormat})) : []; // const pluginOptions = options ? options.find(option => option.format === format).plugins.map(plugin => { // if (plugin.apps) { // const submenu = plugin.apps.map(app => ({ // label: app.isDefault ? `${app.name} (default)` : app.name, // type: 'radio', // checked: openWithApp && app.url === openWithApp.url, // click: () => selectOpenWithApp(app), // icon: electron.remote.nativeImage.createFromDataURL(app.icon).resize({width: 16, height: 16}) // })); // if (plugin.apps[0].isDefault) { // submenu.splice(1, 0, {type: 'separator'}); // } // return { // isBuiltIn: false, // submenu, // value: plugin.title, // label: openWithApp ? openWithApp.name : '' // }; // } // return { // type: openWithApp ? 'normal' : 'radio', // value: plugin.title, // label: plugin.title, // isBuiltIn: plugin.pluginName.startsWith('_') // }; // }) : []; // if (pluginOptions.every(opt => opt.isBuiltIn)) { // pluginOptions.push({ // separator: true // }, { // type: 'normal', // label: 'Get Plugins…', // value: 'open-plugins' // }); // } // const editPluginOptions = editOptions && editOptions.map(option => ({label: option.title, value: option})); // const buttonAction = editPlugin ? openEditPluginConfig : () => selectEditPlugin(editOptions[0]); // return ( //
// { // editPluginOptions && editPluginOptions.length > 0 && ( // <> // { // (!editPlugin || editPlugin.hasConfig) && ( // // ) // } // { // editPlugin && ( //
// //
//
// { setIsOpen(true); }} /> { isOpen && (
{ event.stopPropagation(); }} > { setIsOpen(false); }} />
) }
); }; export default Slider; // Import React from 'react'; // import PropTypes from 'prop-types'; // class Slider extends React.Component { // state = { // isOpen: false // } // show = () => this.setState({isOpen: true}) // hide = () => this.setState({isOpen: false}) // handleChange = event => { // const {onChange} = this.props; // onChange(event.target.value, event.target); // } // handleBlur = event => { // const {onChange} = this.props; // onChange(event.target.value, event.target, {ignoreEmpty: false}); // } // render() { // const {value, max, min} = this.props; // const {isOpen} = this.state; // return ( //
// { isOpen &&
} // // { // isOpen && ( //
event.stopPropagation()}> // //
// //
//
// ) // } // //
// ); // } // } // Slider.propTypes = { // value: PropTypes.number, // max: PropTypes.number, // min: PropTypes.number, // onChange: PropTypes.elementType // }; // export default Slider; ================================================ FILE: renderer/components/editor/options-container.tsx ================================================ import {useState, useEffect, useMemo} from 'react'; import {createContainer} from 'unstated-next'; import {debounce, DebouncedFunc} from 'lodash'; import VideoMetadataContainer from './video-metadata-container'; import VideoControlsContainer from './video-controls-container'; import useEditorOptions, {EditorOptionsState} from 'hooks/editor/use-editor-options'; import {Format, App} from 'common/types'; import useEditorWindowState from 'hooks/editor/use-editor-window-state'; type EditService = EditorOptionsState['editServices'][0]; type SharePlugin = { pluginName: string; serviceTitle: string; app?: App; }; const isFormatMuted = (format: Format) => ['gif', 'apng'].includes(format); const useOptions = () => { const {fps: originalFps} = useEditorWindowState(); const { state: { formats, fpsHistory, editServices }, updateFpsUsage, isLoading } = useEditorOptions(); const metadata = VideoMetadataContainer.useContainer(); const {isMuted, mute, unmute} = VideoControlsContainer.useContainer(); const [format, setFormat] = useState(); const [fps, setFps] = useState(); const [width, setWidth] = useState(); const [height, setHeight] = useState(); const [editPlugin, setEditPlugin] = useState(); const [sharePlugin, setSharePlugin] = useState(); const [wasMuted, setWasMuted] = useState(false); const debouncedUpdateFpsUsage = useMemo(() => { if (!updateFpsUsage) { return; } return debounce(updateFpsUsage, 1000); }, [updateFpsUsage]); const updateFps = (newFps: number, formatName = format) => { setFps(newFps); debouncedUpdateFpsUsage?.({format: formatName, fps: newFps}); }; const updateSharePlugin = (plugin: SharePlugin) => { setSharePlugin(plugin); }; const updateFormat = (formatName: Format) => { debouncedUpdateFpsUsage.flush(); if (metadata.hasAudio) { if (isFormatMuted(formatName) && !isFormatMuted(format)) { setWasMuted(isMuted); mute(); } else if (!isFormatMuted(formatName) && isFormatMuted(format) && !wasMuted) { unmute(); } } const formatOption = formats.find(f => f.format === formatName); const selectedSharePlugin = formatOption.plugins.find(plugin => { return ( plugin.pluginName === sharePlugin.pluginName && plugin.title === sharePlugin.serviceTitle && (plugin.apps?.some(app => app.url === sharePlugin.app?.url) ?? true) ); }) ?? formatOption.plugins.find(plugin => plugin.pluginName !== '_openWith'); setFormat(formatName); setSharePlugin({ pluginName: selectedSharePlugin.pluginName, serviceTitle: selectedSharePlugin.title, app: selectedSharePlugin.apps ? sharePlugin.app : undefined }); updateFps(Math.min(originalFps, fpsHistory[formatName]), formatName); }; useEffect(() => { if (isLoading) { return; } const firstFormat = formats[0]; const formatName = firstFormat.format; setFormat(formatName); const firstPlugin = firstFormat.plugins.find(plugin => plugin.pluginName !== '_openWith'); setSharePlugin(firstPlugin && { pluginName: firstPlugin.pluginName, serviceTitle: firstPlugin.title }); updateFps(Math.min(originalFps, fpsHistory[formatName]), formatName); }, [isLoading]); useEffect(() => { setWidth(metadata.width); setHeight(metadata.height); }, [metadata]); useEffect(() => { if (!editPlugin) { return; } const newPlugin = editServices.find(service => service.pluginName === editPlugin.pluginName && service.title === editPlugin.title); setEditPlugin(newPlugin); }, [editServices]); const setDimensions = (dimensions: {width: number; height: number}) => { setWidth(dimensions.width); setHeight(dimensions.height); }; return { width, height, format, fps, originalFps, editPlugin, formats, editServices, sharePlugin, updateSharePlugin, updateFps, updateFormat, setEditPlugin, setDimensions }; }; const OptionsContainer = createContainer(useOptions); export default OptionsContainer; ================================================ FILE: renderer/components/editor/video-controls-container.tsx ================================================ import {createContainer} from 'unstated-next'; import electron from 'electron'; import {useRef, useState, useEffect} from 'react'; const useVideoControls = () => { const videoRef = useRef(); const currentWindow = electron.remote.getCurrentWindow(); const wasPaused = useRef(true); const transitioningPauseState = useRef>(); const [hasStarted, setHasStarted] = useState(false); const [isMuted, setIsMuted] = useState(false); const [isPaused, setIsPaused] = useState(true); const play = async () => { if (videoRef.current?.paused) { transitioningPauseState.current = videoRef.current.play(); try { await transitioningPauseState.current; setIsPaused(false); } catch {} } }; const pause = async () => { if (videoRef.current && !videoRef.current.paused) { try { await transitioningPauseState.current; } catch {} finally { videoRef.current.pause(); setIsPaused(true); } } }; const mute = () => { setIsMuted(true); videoRef.current.muted = true; }; const unmute = () => { setIsMuted(false); videoRef.current.muted = false; }; const setVideoRef = (video: HTMLVideoElement) => { videoRef.current = video; setIsPaused(video.paused); if (video.paused) { play(); } }; const videoProps = { onCanPlayThrough: hasStarted ? undefined : () => { setHasStarted(true); if (currentWindow.isFocused()) { play(); } }, onLoadedData: () => { const hasAudio = (videoRef.current as any).webkitAudioDecodedByteCount > 0 || Boolean( (videoRef.current as any).audioTracks && (videoRef.current as any).audioTracks.length > 0 ); if (!hasAudio) { mute(); } }, onEnded: () => { play(); } }; useEffect(() => { const blurListener = () => { wasPaused.current = videoRef.current?.paused; if (!wasPaused.current) { pause(); } }; const focusListener = () => { if (!wasPaused.current) { play(); } }; currentWindow.addListener('blur', blurListener); currentWindow.addListener('focus', focusListener); return () => { currentWindow.removeListener('blur', blurListener); currentWindow.removeListener('focus', focusListener); }; }, []); return { isPaused, isMuted, setVideoRef, pause, play, mute, unmute, videoProps }; }; const VideoControlsContainer = createContainer(useVideoControls); export default VideoControlsContainer; ================================================ FILE: renderer/components/editor/video-metadata-container.tsx ================================================ import {createContainer} from 'unstated-next'; import {useRef, useState} from 'react'; import {useShowWindow} from '../../hooks/use-show-window'; const useVideoMetadata = () => { const videoRef = useRef(); const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); const [hasAudio, setHasAudio] = useState(false); const [duration, setDuration] = useState(0); useShowWindow(duration !== 0); const setVideoRef = (video: HTMLVideoElement) => { videoRef.current = video; }; const videoProps = { onLoadedMetadata: () => { setWidth(videoRef.current?.videoWidth); setHeight(videoRef.current?.videoHeight); setDuration(videoRef.current?.duration); }, onLoadedData: () => { const hasAudio = (videoRef.current as any).webkitAudioDecodedByteCount > 0 || Boolean( (videoRef.current as any).audioTracks && (videoRef.current as any).audioTracks.length > 0 ); if (!hasAudio) { videoRef.current.muted = true; } setHasAudio(hasAudio); } }; return { width, height, hasAudio, duration, setVideoRef, videoProps }; }; const VideoMetadataContainer = createContainer(useVideoMetadata); export default VideoMetadataContainer; ================================================ FILE: renderer/components/editor/video-player.tsx ================================================ import Video from './video'; import LeftControls from './controls/left'; import RightControls from './controls/right'; import PlayBar from './controls/play-bar'; const VideoPlayer = () => { return (
); }; export default VideoPlayer; // Import PropTypes from 'prop-types'; // import React from 'react'; // import classNames from 'classnames'; // import Video from './video'; // import LeftControls from './controls/left'; // import RightControls from './controls/right'; // import PlayBar from './controls/play-bar'; // export default class VideoPlayer extends React.Component { // render() { // const {hover} = this.props; // const className = classNames('video-controls', {hover}); // return ( //
//
// ); // } // } // VideoPlayer.propTypes = { // hover: PropTypes.bool // }; ================================================ FILE: renderer/components/editor/video-time-container.tsx ================================================ import {createContainer} from 'unstated-next'; import {useRef, useState, useEffect} from 'react'; const useVideoTime = () => { const videoRef = useRef(); const [startTime, setStartTime] = useState(0); const [endTime, setEndTime] = useState(0); const [duration, setDuration] = useState(0); const [currentTime, setCurrentTime] = useState(0); const setVideoRef = (video: HTMLVideoElement) => { videoRef.current = video; }; const videoProps = { onLoadedMetadata: () => { if (duration === 0) { setDuration(videoRef.current?.duration); setEndTime(videoRef.current?.duration); } }, onEnded: () => { updateTime(startTime); } }; const updateTime = (time: number, ignoreElement = false) => { if (time >= endTime && !videoRef.current.paused) { videoRef.current.currentTime = startTime; setCurrentTime(startTime); } else { if (!ignoreElement) { videoRef.current.currentTime = time; } setCurrentTime(time); } }; const updateStartTime = (time: number) => { if (time < endTime) { videoRef.current.currentTime = time; setStartTime(time); setCurrentTime(time); } }; const updateEndTime = (time: number) => { if (time > startTime) { videoRef.current.currentTime = time; setEndTime(time); setCurrentTime(time); } }; useEffect(() => { if (!videoRef.current) { return; } const interval = setInterval(() => { updateTime(videoRef.current.currentTime ?? 0, true); }, 1000 / 30); return () => { clearInterval(interval); }; }, [startTime, endTime]); return { startTime, endTime, duration, currentTime, updateTime, updateStartTime, updateEndTime, setVideoRef, videoProps }; }; const VideoTimeContainer = createContainer(useVideoTime); export default VideoTimeContainer; ================================================ FILE: renderer/components/editor/video.tsx ================================================ import {useRef, useEffect} from 'react'; import VideoTimeContainer from './video-time-container'; import VideoMetadataContainer from './video-metadata-container'; import VideoControlsContainer from './video-controls-container'; import useEditorWindowState from 'hooks/editor/use-editor-window-state'; import {ipcRenderer as ipc} from 'electron-better-ipc'; const getVideoProps = (propsArray: Array, HTMLVideoElement>>) => { const handlers = new Map(); for (const props of propsArray) { for (const [key, handler] of Object.entries(props)) { if (!handlers.has(key)) { handlers.set(key, []); } handlers.get(key).push(handler); } } // eslint-disable-next-line unicorn/no-array-reduce return [...handlers.entries()].reduce((acc, [key, handlerList]) => ({ ...acc, [key]: () => { for (const handler of handlerList) { handler?.(); } } }), {}); }; const Video = () => { const videoRef = useRef(); const {filePath} = useEditorWindowState(); const src = `file://${filePath}`; const videoTimeContainer = VideoTimeContainer.useContainer(); const videoMetadataContainer = VideoMetadataContainer.useContainer(); const videoControlsContainer = VideoControlsContainer.useContainer(); useEffect(() => { videoTimeContainer.setVideoRef(videoRef.current); videoMetadataContainer.setVideoRef(videoRef.current); videoControlsContainer.setVideoRef(videoRef.current); }, []); const videoProps = getVideoProps([ videoTimeContainer.videoProps, videoMetadataContainer.videoProps, videoControlsContainer.videoProps ]); const onContextMenu = async () => { const video = videoRef.current; if (!video) { return; } const wasPaused = video.paused; if (!wasPaused) { await videoControlsContainer.pause(); } const {Menu} = require('electron-util').api; const menu = Menu.buildFromTemplate([{ label: 'Snapshot', click: () => { ipc.callMain('save-snapshot', video.currentTime); } }]); menu.popup({ callback: () => { if (!wasPaused) { videoControlsContainer.play(); } } }); }; return (
); }; export default Video; ================================================ FILE: renderer/components/exports/export.tsx ================================================ import electron from 'electron'; import React, {useCallback, useMemo} from 'react'; import classNames from 'classnames'; import IconMenu from '../icon-menu'; import {CancelIcon, MoreIcon} from '../../vectors'; import {Progress, ProgressSpinner} from './progress'; import useConversion from '../../hooks/editor/use-conversion'; import {ExportStatus} from '../../common/types'; import {useShowWindow} from '../../hooks/use-show-window'; import {MenuItemConstructorOptions} from 'electron/common'; const stopPropagation = event => event.stopPropagation(); const Export = ({id}: {id: string}) => { const {state, isLoading, cancel, openInEditor, showInFolder, retry, copy} = useConversion(id); useShowWindow(state?.id !== undefined); const isCancelable = state?.status === ExportStatus.inProgress; const isActionable = state?.filePath && !state?.disableOutputActions; const canRetry = [ExportStatus.canceled, ExportStatus.failed].includes(state?.status); const onCancel = () => { cancel(); }; const onClick = () => { showInFolder(); }; const fileNameClassName = classNames({ title: true, disabled: !isActionable }); const onDragStart = useCallback(event => { event.preventDefault(); if (isActionable) { electron.ipcRenderer.send('drag-export', state?.id); } }, [isActionable, state?.id]); const template = useMemo(() => { const menuTemplate: MenuItemConstructorOptions[] = [{ label: 'Open Original', click: () => openInEditor() }]; if (state?.canCopy) { menuTemplate.unshift({ label: 'Copy', click: () => copy() }, { type: 'separator' }); } if (canRetry) { menuTemplate.unshift({ label: 'Retry', click: () => retry() }, { type: 'separator' }); } return menuTemplate; }, [canRetry, retry, openInEditor, state?.canCopy, copy]); if (isLoading) { return null; } return (
{ isCancelable ?
: }
{ state.status === ExportStatus.inProgress && ( state.progress === 0 ? : ) }
{state.titleWithFormat}
{state.message}{state.error && ` - ${state.error.message}`}
); }; export default Export; ================================================ FILE: renderer/components/exports/index.tsx ================================================ import React, {useMemo} from 'react'; import useExportsList from '../../hooks/exports/use-exports-list'; import Export from './export'; const Exports = () => { const {state = []} = useExportsList(); const exportList = useMemo(() => state.reverse(), [state]); return (
{ exportList.map(id => ( )) }
); }; export default Exports; ================================================ FILE: renderer/components/exports/progress.tsx ================================================ import PropTypes from 'prop-types'; import React from 'react'; import {SpinnerIcon} from '../../vectors'; export const ProgressSpinner = () => (
); export const Progress = ({percent}: {percent: number}) => { const circumference = 12 * 2 * Math.PI; const offset = circumference - (percent * circumference); return ( ); }; ================================================ FILE: renderer/components/icon-menu.tsx ================================================ import {MenuItemConstructorOptions} from 'electron'; import React, {FunctionComponent, useRef} from 'react'; import {SvgProps} from 'vectors/svg'; type MenuProps = { onOpen: (options: {x: number; y: number}) => void; } | { template: MenuItemConstructorOptions[]; }; type IconMenuProps = SvgProps & MenuProps & { icon: FunctionComponent; fillParent?: boolean; }; const IconMenu: FunctionComponent = props => { const {icon: Icon, fillParent, ...iconProps} = props; const container = useRef(null); const openMenu = () => { const boundingRect = container.current.children[0].getBoundingClientRect(); const {bottom, left} = boundingRect; if ('onOpen' in props) { props.onOpen({ x: Math.round(left), y: Math.round(bottom) }); } else { const {api} = require('electron-util'); const menu = api.Menu.buildFromTemplate(props.template); menu.popup({ x: Math.round(left), y: Math.round(bottom) }); } }; return (
); }; export default IconMenu; ================================================ FILE: renderer/components/keyboard-number-input.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; import {handleInputKeyPress} from '../utils/inputs'; class KeyboardNumberInput extends React.Component { constructor(props) { super(props); this.inputRef = React.createRef(); } getRef = () => { return this.inputRef; }; render() { const {onChange, min, max, ...rest} = this.props; return ( ); } } KeyboardNumberInput.propTypes = { onKeyDown: PropTypes.elementType, min: PropTypes.number, max: PropTypes.number, onChange: PropTypes.elementType }; export default KeyboardNumberInput; ================================================ FILE: renderer/components/preferences/categories/category.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; class Category extends React.Component { render() { return (
{this.props.children}
); } } Category.propTypes = { children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node ]).isRequired }; export default Category; ================================================ FILE: renderer/components/preferences/categories/general.js ================================================ import electron from 'electron'; import React from 'react'; import PropTypes from 'prop-types'; import tildify from 'tildify'; import {connect, PreferencesContainer} from '../../../containers'; import Item from '../item'; import Switch from '../item/switch'; import Button from '../item/button'; import Select from '../item/select'; import ShortcutInput from '../shortcut-input'; import Category from './category'; class General extends React.Component { static defaultProps = { audioDevices: [], kapturesDir: '', category: 'general' }; state = {}; componentDidMount() { this.setState({ showCursorSupported: electron.remote.require('macos-version').isGreaterThanOrEqualTo('10.13') }); } openKapturesDir = () => { electron.shell.openPath(this.props.kapturesDir); }; render() { const { kapturesDir, openOnStartup, allowAnalytics, showCursor, highlightClicks, record60fps, enableShortcuts, loopExports, toggleSetting, toggleRecordAudio, audioInputDeviceId, setAudioInputDeviceId, audioDevices, recordAudio, pickKapturesDir, setOpenOnStartup, updateShortcut, toggleShortcuts, category, lossyCompression, shortcuts, shortcutMap } = this.props; const {showCursorSupported} = this.state; const devices = audioDevices.map(device => ({ label: device.name, value: device.id })); const kapturesDirPath = tildify(kapturesDir); const tabIndex = category === 'general' ? 0 : -1; const fpsOptions = [{label: '30 FPS', value: false}, {label: '60 FPS', value: true}]; return ( { showCursorSupported && { if (showCursor) { toggleSetting('highlightClicks', false); } toggleSetting('showCursor'); } }/> } { showCursorSupported && toggleSetting('highlightClicks')} /> } { enableShortcuts && Object.entries(shortcutMap).map(([key, title]) => ( updateShortcut(key, shortcut)} /> )) } toggleSetting('loopExports')}/> { recordAudio && toggleSetting('record60fps', value)}/> toggleSetting('allowAnalytics')}/> ); Button.propTypes = { title: PropTypes.string, onClick: PropTypes.func.isRequired, tabIndex: PropTypes.number.isRequired }; export default Button; ================================================ FILE: renderer/components/preferences/item/color-picker.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; const ColorPicker = ({hasErrors, value, onChange}) => { const className = classNames('container', {'has-errors': hasErrors}); const handleChange = event => { const value = event.currentTarget.value.toUpperCase(); onChange(value.startsWith('#') ? value : `#${value}`); }; return (
{ event.currentTarget.select(); }} />
); }; ColorPicker.propTypes = { value: PropTypes.string, onChange: PropTypes.elementType, hasErrors: PropTypes.bool }; export default ColorPicker; ================================================ FILE: renderer/components/preferences/item/index.js ================================================ import electron from 'electron'; import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import Linkify from 'react-linkify'; import {HelpIcon} from '../../../vectors'; export const Link = ({href, children}) => ( electron.shell.openExternal(href)}> {children} ); Link.propTypes = { href: PropTypes.string, children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node ]) }; class Item extends React.Component { static defaultProps = { subtitle: [], errors: [] }; render() { const { title, subtitle, experimental, tooltip, children, id, vertical, errors, onSubtitleClick, warning, onClick, last, parentItem, small, help } = this.props; const subtitleArray = Array.isArray(subtitle) ? subtitle : [subtitle]; const className = classNames('title', {experimental}); const containerClassName = classNames('container', {parent: parentItem}); const subtitleClassName = classNames('subtitle', {link: Boolean(onSubtitleClick)}); return (
{warning}
{title} { help && (
) }
{ subtitleArray.map(s =>
{s}
) }
{children}
{errors && errors.length > 0 && (
{errors.map(error =>
{error}
)}
)}
); } } Item.propTypes = { help: PropTypes.string, id: PropTypes.string, title: PropTypes.oneOfType([ PropTypes.string, PropTypes.arrayOf(PropTypes.node), PropTypes.node ]), experimental: PropTypes.bool, tooltip: PropTypes.string, subtitle: PropTypes.oneOfType([ PropTypes.string, PropTypes.arrayOf(PropTypes.string) ]), children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node ]), vertical: PropTypes.bool, errors: PropTypes.arrayOf(PropTypes.string), onSubtitleClick: PropTypes.elementType, warning: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node ]), onClick: PropTypes.elementType, last: PropTypes.bool, parentItem: PropTypes.bool, small: PropTypes.bool }; export default Item; ================================================ FILE: renderer/components/preferences/item/select.js ================================================ import electron from 'electron'; import PropTypes from 'prop-types'; import React from 'react'; import {DropdownArrowIcon} from '../../../vectors'; import {handleKeyboardActivation} from '../../../utils/inputs'; class Select extends React.Component { static defaultProps = { options: [], placeholder: 'Select', noOptionsMessage: 'No options' }; constructor(props) { super(props); this.select = React.createRef(); } state = {}; static getDerivedStateFromProps(nextProps) { const {options, onSelect, selected} = nextProps; if (!electron.remote || options.length === 0) { return {}; } const {Menu, MenuItem} = electron.remote; const menu = new Menu(); for (const option of options) { menu.append( new MenuItem({ label: option.label, type: 'radio', checked: option.value === selected, click: () => onSelect(option.value) }) ); } return {menu}; } handleClick = () => { if (this.props.options.length > 0) { const boundingRect = this.select.current.getBoundingClientRect(); this.state.menu.popup({ x: Math.round(boundingRect.left), y: Math.round(boundingRect.top) }); } }; render() { const {options, selected, placeholder, noOptionsMessage, tabIndex, full} = this.props; const selectedLabel = options.length === 0 ? noOptionsMessage : ( selected === undefined ? placeholder : options.find(option => option.value === selected).label ); return (
{selectedLabel}
); } } Select.propTypes = { options: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string, value: PropTypes.any })), onSelect: PropTypes.elementType.isRequired, selected: PropTypes.any, placeholder: PropTypes.string, noOptionsMessage: PropTypes.string, tabIndex: PropTypes.number.isRequired, full: PropTypes.bool }; export default Select; ================================================ FILE: renderer/components/preferences/item/switch.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import {SpinnerIcon} from '../../../vectors'; import {handleKeyboardActivation} from '../../../utils/inputs'; class Switch extends React.Component { render() { const {checked, onClick, disabled, loading, onTransitionEnd, tabIndex} = this.props; const className = classNames('switch', {checked, disabled, loading}); return (
{loading && }
); } } Switch.propTypes = { checked: PropTypes.bool, disabled: PropTypes.bool, loading: PropTypes.bool, onClick: PropTypes.func.isRequired, onTransitionEnd: PropTypes.func, tabIndex: PropTypes.number.isRequired }; export default Switch; ================================================ FILE: renderer/components/preferences/navigation.js ================================================ import classNames from 'classnames'; import React from 'react'; import PropTypes from 'prop-types'; import {connect, PreferencesContainer} from '../../containers'; import {SettingsIcon, PluginsIcon} from '../../vectors'; import {handleKeyboardActivation} from '../../utils/inputs'; const CATEGORIES = [ { name: 'general', icon: SettingsIcon }, { name: 'plugins', icon: PluginsIcon } ]; class PreferencesNavigation extends React.Component { static defaultProps = { category: 'general' }; render() { const {selectCategory, category} = this.props; return ( ); } } PreferencesNavigation.propTypes = { category: PropTypes.string, selectCategory: PropTypes.elementType.isRequired }; export default connect( [PreferencesContainer], ({category}) => ({category}), ({selectCategory}) => ({selectCategory}) )(PreferencesNavigation); ================================================ FILE: renderer/components/preferences/shortcut-input.js ================================================ import React, {useRef, useEffect, useState} from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import {shake} from '../../utils/inputs'; import {checkAccelerator, eventKeyToAccelerator} from 'common/accelerator-validator'; import {DropdownArrowIcon} from '../../vectors'; const presets = [ 'Command+Shift+3', 'Command+Shift+4', 'Command+Shift+5', 'Command+Shift+6' ]; const Key = ({children}) => ( {children} ); Key.propTypes = { children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node ]).isRequired }; const metaCharacters = new Map([ ['Command', '⌘'], ['Alt', '⌥'], ['Option', '⌥'], ['Shift', '⇧'], ['Cmd', '⌘'], ['Control', '⌃'], ['Ctrl', '⌃'] ]); const ShortcutInput = ({shortcut = '', onChange, tabIndex}) => { const [keys, setKeys] = useState(shortcut.split('+').filter(Boolean)); const [isEditing, setIsEditing] = useState(false); const boxRef = useRef(); const inputRef = useRef(); const resetKeys = () => { setKeys(shortcut.split('+').filter(Boolean)); }; useEffect(() => { resetKeys(); }, [shortcut]); const keysToRender = keys.map(key => metaCharacters.get(key) || key); const clearShortcut = () => { setKeys([]); onChange(undefined); }; const cancel = event => { const {metaKey, altKey, ctrlKey, shiftKey, key} = event; const metaKeys = [ metaKey && 'Command', altKey && 'Alt', ctrlKey && 'Control', shiftKey && 'Shift' ].filter(Boolean); if (metaKeys.length > 0 && ['Shift', 'Control', 'Alt', 'Meta'].includes(key)) { setKeys(metaKeys); return; } shake(boxRef.current); resetKeys(); setIsEditing(false); }; const handleKeyDown = event => { // TODO: Use `code` instead of `keyCode` when this is released https://github.com/facebook/react/pull/18287 const {metaKey, altKey, ctrlKey, shiftKey, key, location, keyCode} = event; const metaKeys = [ metaKey && 'Command', altKey && 'Alt', ctrlKey && 'Control', shiftKey && 'Shift' ].filter(Boolean); if (metaKeys.length === 0) { if (key === 'Tab') { return; } if (['Escape', 'Delete', 'Backspace'].includes(key)) { clearShortcut(); return; } } // Handled by the `onPaste` event if (metaKeys.length === 1 && metaKey && key.toUpperCase() === 'V') { return; } if (['Shift', 'Control', 'Alt', 'Meta'].includes(key)) { setKeys(metaKeys); setIsEditing(true); return; } const mappedKey = (keyCode > 47 && keyCode < 58) || (keyCode > 64 && keyCode < 91) ? String.fromCharCode(keyCode) : key; const keys = [...metaKeys, eventKeyToAccelerator(mappedKey, location)]; const accelerator = keys.join('+'); setIsEditing(false); if (checkAccelerator(accelerator)) { setKeys(keys); onChange(accelerator); } else { shake(boxRef.current); resetKeys(); } }; const paste = event => { const text = (event.clipboardData || window.clipboardData).getData('text'); setIsEditing(false); if (checkAccelerator(text)) { setKeys(text.split('+').filter(Boolean)); onChange(text); } else { shake(boxRef.current); resetKeys(); } }; const openMenu = () => { const {Menu} = require('electron').remote; const menu = Menu.buildFromTemplate(presets.map(accelerator => ({ label: accelerator.split('+').map(key => metaCharacters.get(key) || key).join(''), click: () => { onChange(accelerator); } }))); const {left, top} = boxRef.current.getBoundingClientRect(); menu.popup({ x: Math.round(left), y: Math.round(top) }); }; const className = classNames('box', {invalid: false}); return (
inputRef.current.focus()}>
{keysToRender.map(key => {key})}
); }; ShortcutInput.propTypes = { shortcut: PropTypes.string, onChange: PropTypes.func.isRequired, tabIndex: PropTypes.number.isRequired }; export default ShortcutInput; ================================================ FILE: renderer/components/traffic-lights.tsx ================================================ import {remote} from 'electron'; import {useState, useEffect, FunctionComponent} from 'react'; interface TrafficLightsProps { shouldClose?: () => PromiseLike; } const TrafficLights: FunctionComponent = props => { const currentWindow = remote.getCurrentWindow(); const [tint, setTint] = useState('blue'); useEffect(() => { const setTintColor = () => { setTint(remote.systemPreferences.getUserDefault('AppleAquaColorVariant', 'string') === '6' ? 'graphite' : 'blue'); }; const tintSubscription = remote.systemPreferences.subscribeNotification('AppleAquaColorVariantChanged', setTintColor); setTintColor(); return () => { remote.systemPreferences.unsubscribeNotification(tintSubscription); }; }, []); const enabled = { close: currentWindow.closable, minimize: currentWindow.minimizable, maximize: currentWindow.maximizable }; const getClassName = (name: string) => `traffic-light ${name}${enabled[name] ? '' : ' disabled'}`; const close = async () => { if (!props.shouldClose || await props.shouldClose()) { currentWindow.close(); } }; const minimize = () => { currentWindow.minimize(); }; const maximize = () => { currentWindow.setFullScreen(!currentWindow.isFullScreen()); }; return (
); }; export default TrafficLights; ================================================ FILE: renderer/components/window-header.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; class WindowHeader extends React.Component { render() { return (
{this.props.title} {this.props.children}
); } } WindowHeader.propTypes = { title: PropTypes.string, children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node ]) }; export default WindowHeader; ================================================ FILE: renderer/containers/action-bar.js ================================================ import electron from 'electron'; import {Container} from 'unstated'; const barWidth = 464; const barHeight = 64; export default class ActionBarContainer extends Container { remote = electron.remote || false; constructor() { super(); if (!this.remote) { this.state = {}; return; } this.settings = this.remote.require('./common/settings').settings; this.state = { cropperWidth: '', cropperHeight: '' }; } setInputValues = ({width, height}) => { this.setState({ cropperWidth: width ? width.toString() : '', cropperHeight: height ? height.toString() : '' }); }; setWidth = cropperWidth => { this.setState({cropperWidth}); }; setHeight = cropperHeight => { this.setState({cropperHeight}); }; setDisplay = display => { const {width, height, cropper} = display; const {x, y, ratioLocked} = cropper ? this.settings.get('actionBar') : {}; this.setState({ screenWidth: width, screenHeight: height, x: x ? x : (width - barWidth) / 2, y: y ? y : Math.ceil(height * 0.8), width: barWidth, height: barHeight, ratioLocked }); }; resetPosition = () => { const {screenWidth, screenHeight} = this.state; this.setState({ x: (screenWidth - barWidth) / 2, y: Math.ceil(screenHeight * 0.8), width: barWidth, height: barHeight }); }; bindCursor = cursorContainer => { this.cursorContainer = cursorContainer; }; bindCropper = cropperContainer => { this.cropperContainer = cropperContainer; }; updateSettings = updates => { const {x, y, ratioLocked} = this.state; this.settings.set('actionBar', { x, y, ratioLocked, ...updates }); this.setState(updates); }; toggleRatioLock = ratioLocked => { const {ratioLocked: isLocked} = this.state; if (ratioLocked) { this.updateSettings({ratioLocked}); } else { this.updateSettings({ratioLocked: !isLocked}); } this.cropperContainer.setOriginal(); }; toggleAdvanced = () => { if (!this.cropperContainer.state.isFullscreen) { const {advanced, screenWidth} = this.state; this.updateSettings({advanced: !advanced}); if (!advanced) { const width = screenWidth * 0.2; const height = width * 0.75; // 4:3 this.cropperContainer.setSize({ width, height }); } } }; startMoving = ({pageX, pageY}) => { this.setState({isMoving: true, offsetX: pageX, offsetY: pageY}); this.cursorContainer.addCursorObserver(this.move); }; stopMoving = () => { const {x, y} = this.state; this.updateSettings({x, y}); this.setState({isMoving: false}); this.cursorContainer.removeCursorObserver(this.move); }; move = ({pageX, pageY}) => { const {x, y, offsetX, offsetY, height, width, screenWidth, screenHeight} = this.state; const updates = { offsetX: pageX, offsetY: pageY }; if (y + pageY - offsetY + height <= screenHeight && y + pageY - offsetY >= 0) { updates.y = y + pageY - offsetY; } if (x + pageX - offsetX + width <= screenWidth && x + pageX - offsetX >= 0) { updates.x = x + pageX - offsetX; } this.setState(updates); }; } ================================================ FILE: renderer/containers/config.js ================================================ import electron from 'electron'; import {Container} from 'unstated'; export default class ConfigContainer extends Container { remote = electron.remote || false; state = {selectedTab: 0}; setPlugin(pluginName) { const {InstalledPlugin} = this.remote.require('./plugins/plugin'); this.plugin = new InstalledPlugin(pluginName); this.config = this.plugin.config; this.validators = this.config.validators; this.validate(); this.setState({ validators: this.validators, values: this.config.store, pluginName }); } setEditService = (pluginName, serviceTitle) => { const {InstalledPlugin} = this.remote.require('./plugins/plugin'); this.plugin = new InstalledPlugin(pluginName); this.config = this.plugin.config; this.validators = this.config.validators.filter(({title}) => title === serviceTitle); this.validate(); this.setState({ validators: this.validators, values: this.config.store, pluginName, serviceTitle }); }; validate = () => { for (const validator of this.validators) { validator.validate(this.config.store); } }; closeWindow = () => this.remote.getCurrentWindow().close(); openConfig = () => this.plugin.openConfigInEditor(); viewOnGithub = () => this.plugin.viewOnGithub(); onChange = (key, value) => { if (value === undefined) { this.config.delete(key); } else { this.config.set(key, value); } this.validate(); this.setState({values: this.config.store}); }; selectTab = selectedTab => { this.setState({selectedTab}); }; } ================================================ FILE: renderer/containers/cropper.js ================================================ import electron from 'electron'; import nearestNormalAspectRatio from 'nearest-normal-aspect-ratio'; import {Container} from 'unstated'; import {minHeight, minWidth, resizeTo, setScreenSize} from '../utils/inputs'; // Helper function for retrieving the simplest ratio, // via the largest common divisor of two numbers (thanks @doot0) const getLargestCommonDivisor = (first, second) => { if (!first) { return 1; } if (!second) { return first; } return getLargestCommonDivisor(second, first % second); }; const getSimplestRatio = (width, height) => { const lcd = getLargestCommonDivisor(width, height); const denominator = width / lcd; const numerator = height / lcd; return [denominator, numerator]; }; export const findRatioForSize = (width, height) => { const ratio = nearestNormalAspectRatio(width, height); if (ratio) { return ratio.split(':').map(part => Number.parseInt(part, 10)); } return getSimplestRatio(width, height); }; export default class CropperContainer extends Container { remote = electron.remote || false; constructor() { super(); if (!this.remote) { this.state = {}; return; } const {settings} = this.remote.require('./common/settings'); this.settings = settings; this.settings.getSelectedInputDeviceId = this.remote.require('./utils/devices').getSelectedInputDeviceId; this.state = { isRecording: false, isResizing: false, isMoving: false, isPicking: false, resizeFromCenter: false, showHandles: true, selectedApp: '', screenWidth: 0, screenHeight: 0, isActive: false, isReady: false, ratio: [1, 1], recordAudio: this.settings.get('recordAudio'), audioInputDeviceId: this.settings.getSelectedInputDeviceId() }; this.settings.onDidChange('recordAudio', recordAudio => { this.setState({recordAudio}); }); this.settings.onDidChange('audioInputDeviceId', async () => { this.setState({audioInputDeviceId: this.settings.getSelectedInputDeviceId()}); }); } setDisplay = display => { const {width: screenWidth, height: screenHeight, isActive, id, cropper = {}} = display; const {x, y, width, height, ratio = [4, 3]} = cropper; setScreenSize(screenWidth, screenHeight); this.setState({ screenWidth, screenHeight, isActive, isReady: true, displayId: id, x: x || screenWidth / 2, y: y || screenHeight / 2, width, height, ratio }); this.actionBarContainer.setInputValues({width, height}); }; willStartRecording = () => { this.setState({willStartRecording: true}); }; setRecording = () => { this.setState({isRecording: true}); }; setActive = isActive => { const updates = {isActive}; if (!isActive) { updates.x = 0; updates.y = 0; updates.width = 0; updates.height = 0; updates.isFullscreen = false; updates.showHandles = true; updates.selectedApp = ''; } this.setState(updates); }; updateSettings = updates => { const {x, y, width, height, ratio, displayId} = this.state; this.settings.set('cropper', { x, y, width, height, ratio, ...updates, displayId }); this.setState(updates); }; setSize = ({width: defaultWidth, height: defaultHeight}) => { let {width, height} = this.state; width = width || defaultWidth; height = height || defaultHeight; const updates = {width, height}; this.settings.set('cropper', updates); this.setState(updates); this.actionBarContainer.setInputValues(updates); }; bindCursor = cursorContainer => { this.cursorContainer = cursorContainer; }; bindActionBar = actionBarContainer => { this.actionBarContainer = actionBarContainer; }; setBounds = (bounds, {save = true, ignoreRatioLocked} = {}) => { if (bounds) { const updates = bounds; if ((!this.actionBarContainer.state.ratioLocked || ignoreRatioLocked) && (bounds.width || bounds.height)) { const {width, height} = this.state; updates.ratio = findRatioForSize(bounds.width || width, bounds.height || height); } if (save) { this.updateSettings(updates); } else { this.setState(updates); } this.actionBarContainer.setInputValues(updates); } else if (this.state.width || this.state.height) { this.actionBarContainer.setInputValues(this.state); } else { this.actionBarContainer.setInputValues({}); } }; setRatio = ratio => { const {x, y, width, screenHeight} = this.state; const target = {width}; this.unselectApp(); const computedHeight = Math.ceil(width * ratio[1] / ratio[0]); target.height = Math.max(minHeight, Math.min(screenHeight, computedHeight)); if (target.height !== computedHeight) { target.width = Math.ceil(target.height * ratio[0] / ratio[1]); } const updates = {ratio, ...resizeTo({x, y}, target)}; this.updateSettings(updates); this.actionBarContainer.setInputValues(updates); this.actionBarContainer.toggleRatioLock(true); }; swapDimensions = () => { const {x, y, width, height, ratio, screenHeight} = this.state; const target = { width: height, height: Math.min(width, screenHeight) }; this.unselectApp(); if (target.height !== width) { target.width = Math.ceil(target.height * ratio[1] / ratio[0]); } const updates = {ratio: ratio.reverse(), ...resizeTo({x, y}, target)}; this.updateSettings(updates); this.actionBarContainer.setInputValues(updates); }; selectApp = app => { const {x, y, width, height, ownerName} = app; this.setState({selectedApp: ownerName}); this.setBounds({x, y, width, height}, {ignoreRatioLocked: true}); }; unselectApp = () => { if (this.state.selectedApp) { this.setState({selectedApp: ''}); } }; toggleResizeFromCenter = resizeFromCenter => { this.setState({resizeFromCenter}); }; enterFullscreen = () => { const {x, y, width, height, screenWidth, screenHeight} = this.state; this.unselectApp(); this.setState({ isFullscreen: true, x: 0, y: 0, width: screenWidth, height: screenHeight, showHandles: false, original: {x, y, width, height} }); }; exitFullscreen = () => { const {original} = this.state; this.setState({isFullscreen: false, showHandles: true, ...original}); }; startPicking = ({pageX, pageY}) => { this.unselectApp(); this.setState({isPicking: true, original: {pageX, pageY}}); this.cursorContainer.addCursorObserver(this.pick); }; pick = ({pageX, pageY}) => { const {original, isPicking} = this.state; const width = Math.abs(original.pageX - pageX); const height = Math.abs(original.pageY - pageY); if ((width > 0 || height > 0) && isPicking) { this.cursorContainer.removeCursorObserver(this.pick); const top = pageY < original.pageY; const left = pageX < original.pageX; this.setState({ x: Math.min(pageX, original.pageX), y: Math.min(pageY, original.pageY), width, height, isResizing: true, isPicking: false, currentHandle: {top, bottom: !top, left, right: !left} }); this.setOriginal(); this.cursorContainer.addCursorObserver(this.resize); } }; stopPicking = () => { if (this.state.isPicking) { this.remote.getCurrentWindow().close(); } else { this.cursorContainer.removeCursorObserver(this.pick); } }; setOriginal = () => { const {x, y, width, height} = this.state; this.setState({original: {x, y, width, height}}); }; startResizing = currentHandle => { if (!this.state.isFullscreen) { this.unselectApp(); this.setOriginal(); this.setState({currentHandle, isResizing: true}); this.cursorContainer.addCursorObserver(this.resize); } }; stopResizing = () => { if (!this.state.isFullscreen && this.state.isResizing) { const {x, y, width, height, ratio} = this.state; this.setState({currentHandle: null, isResizing: false, showHandles: true, isPicking: false}); this.cursorContainer.removeCursorObserver(this.resize); this.setBounds({ ...resizeTo({x, y}, { width: Math.max(minWidth, width), height: Math.max(minHeight, height) }), ratio }); } }; startMoving = ({pageX, pageY}) => { if (!this.state.isFullscreen) { this.unselectApp(); this.setState({isMoving: true, showHandles: false, offsetX: pageX, offsetY: pageY}); this.cursorContainer.addCursorObserver(this.move); } }; stopMoving = () => { if (!this.state.isFullscreen && this.state.isMoving) { const {x, y, width, height} = this.state; this.setBounds({x, y, width, height}); this.setState({isMoving: false, showHandles: true}); this.cursorContainer.removeCursorObserver(this.move); this.updateSettings({x, y}); } }; move = ({pageX, pageY}) => { const {x, y, offsetX, offsetY, width, height, screenWidth, screenHeight} = this.state; const updates = { offsetY: pageY, offsetX: pageX }; if (y + pageY - offsetY + height <= screenHeight && y + pageY - offsetY >= 0) { updates.y = y + pageY - offsetY; } if (x + pageX - offsetX + width <= screenWidth && x + pageX - offsetX >= 0) { updates.x = x + pageX - offsetX; } this.setBounds(updates, {save: false}); }; resize = ({pageX, pageY}) => { const {currentHandle, x, y, width, height, original, ratio, screenWidth, screenHeight, resizeFromCenter} = this.state; const {top, bottom, left, right} = currentHandle; const {ratioLocked} = this.actionBarContainer.state; const updates = {currentHandle: {top, bottom, right, left}}; if (top) { updates.y = pageY; updates.height = height + y - pageY; if (resizeFromCenter) { updates.height = Math.min((2 * (screenHeight - y)) - height, updates.height + y - pageY); updates.y = y - ((updates.height - height) / 2); } } else if (bottom) { updates.height = pageY - y; updates.y = y; if (resizeFromCenter) { updates.y = Math.max(0, y + height - updates.height); updates.height = height + (2 * (y - updates.y)); } } if (updates.height !== undefined && updates.height < 0 && !ratioLocked) { updates.y += updates.height; updates.height = -updates.height; updates.currentHandle.bottom = !bottom; updates.currentHandle.top = !top; } if (left) { updates.x = pageX; updates.width = width + x - pageX; if (resizeFromCenter) { updates.width = Math.min((2 * (screenWidth - x)) - width, updates.width + x - pageX); updates.x = x - ((updates.width - width) / 2); } } else if (right) { updates.width = pageX - x; updates.x = x; if (resizeFromCenter) { updates.x = Math.max(0, x + width - updates.width); updates.width = width + (2 * (x - updates.x)); } } if (updates.width !== undefined && updates.width < 0 && !ratioLocked) { updates.x += updates.width; updates.width = -updates.width; updates.currentHandle.left = !left; updates.currentHandle.right = !right; } if (ratioLocked) { if (updates.width < 0 && updates.height < 0) { updates.currentHandle = {bottom: !bottom, top: !top, left: !left, right: !right}; } // Check which dimension has changed the most if ( (updates.width - original.width) * ratio[1] > (updates.height - original.height) * ratio[0] ) { let lockedHeight = Math.ceil(updates.width * ratio[1] / ratio[0]); if (resizeFromCenter) { updates.y += (updates.height - lockedHeight) / 2; if (updates.y < 0 || updates.y + lockedHeight > screenHeight) { if (updates.y < 0) { lockedHeight += updates.y * 2; updates.y = 0; } else { lockedHeight -= (lockedHeight - (screenHeight - updates.y)) * 2; updates.y = screenHeight - lockedHeight; } const lockedWidth = Math.ceil(lockedHeight * ratio[0] / ratio[1]); updates.x += (updates.width - lockedWidth) / 2; updates.width = lockedWidth; } } else if (top) { updates.y += updates.height - lockedHeight; if (updates.y < 0) { lockedHeight += updates.y; const lockedWidth = Math.ceil(lockedHeight * ratio[0] / ratio[1]); updates.y = 0; if (left) { updates.x += updates.width - lockedWidth; } updates.width = lockedWidth; } } else if (updates.y + lockedHeight > screenHeight) { lockedHeight = screenHeight - updates.y; const lockedWidth = Math.ceil(lockedHeight * ratio[0] / ratio[1]); if (left) { updates.x += updates.width - lockedWidth; } updates.width = lockedWidth; } updates.height = lockedHeight; } else { let lockedWidth = Math.ceil(updates.height * ratio[0] / ratio[1]); if (resizeFromCenter) { updates.x += (updates.width - lockedWidth) / 2; if (updates.x < 0 || updates.x + lockedWidth > screenWidth) { if (updates.x < 0) { lockedWidth += updates.x * 2; updates.x = 0; } else { lockedWidth -= (lockedWidth - (screenWidth - updates.x)) * 2; updates.x = screenWidth - lockedWidth; } const lockedHeight = Math.ceil(lockedWidth * ratio[1] / ratio[0]); updates.y += (updates.height - lockedHeight) / 2; updates.height = lockedHeight; } } else if (left) { updates.x += updates.width - lockedWidth; if (updates.x < 0) { lockedWidth += updates.x; const lockedHeight = Math.ceil(lockedWidth * ratio[1] / ratio[0]); updates.x = 0; if (top) { updates.y += updates.height - lockedHeight; } updates.height = lockedHeight; } } else if (updates.x + lockedWidth > screenWidth) { lockedWidth = screenWidth - updates.x; const lockedHeight = Math.ceil(lockedWidth * ratio[1] / ratio[0]); if (top) { updates.y += updates.height - lockedHeight; } updates.height = lockedHeight; } updates.width = lockedWidth; } } this.setBounds(updates, {save: false}); }; } ================================================ FILE: renderer/containers/cursor.js ================================================ import {Container} from 'unstated'; export default class CursorContainer extends Container { state = { observers: [] }; setCursor = ({pageX, pageY}) => { this.setState({cursorX: pageX, cursorY: pageY}); for (const observer of this.state.observers) { observer({pageX, pageY}); } }; addCursorObserver = observer => { const {observers} = this.state; this.setState({observers: [observer, ...observers]}); }; removeCursorObserver = observer => { const {observers} = this.state; this.setState({observers: observers.filter(o => o !== observer)}); }; } ================================================ FILE: renderer/containers/index.js ================================================ import React from 'react'; import {Subscribe} from 'unstated'; import CropperContainer from './cropper'; import CursorContainer from './cursor'; import ActionBarContainer from './action-bar'; import PreferencesContainer from './preferences'; import ConfigContainer from './config'; export const connect = (containers, mapStateToProps, mapActionsToProps) => Component => props => ( { (...containers) => { const stateProps = mapStateToProps ? mapStateToProps(...containers.map(a => a.state)) : {}; const actionProps = mapActionsToProps ? mapActionsToProps(...containers) : {}; const componentProps = {...props, ...stateProps, ...actionProps}; return ; } } ); export { CropperContainer, CursorContainer, ActionBarContainer, PreferencesContainer, ConfigContainer }; ================================================ FILE: renderer/containers/preferences.js ================================================ import electron from 'electron'; import {Container} from 'unstated'; import {ipcRenderer as ipc} from 'electron-better-ipc'; // Import {defaultInputDeviceId} from 'common/constants'; const defaultInputDeviceId = 'asd'; const SETTINGS_ANALYTICS_BLACKLIST = ['kapturesDir']; export default class PreferencesContainer extends Container { remote = electron.remote || false; state = { category: 'general', tab: 'discover', isMounted: false }; mount = async setOverlay => { this.setOverlay = setOverlay; const {settings, shortcuts} = this.remote.require('./common/settings'); this.settings = settings; this.settings.shortcuts = shortcuts; this.systemPermissions = this.remote.require('./common/system-permissions'); this.plugins = this.remote.require('./plugins').plugins; this.track = this.remote.require('./common/analytics').track; this.showError = this.remote.require('./utils/errors').showError; const pluginsInstalled = this.plugins.installedPlugins.sort((a, b) => a.prettyName.localeCompare(b.prettyName)); this.fetchFromNpm(); this.setState({ shortcuts: {}, ...this.settings.store, openOnStartup: this.remote.app.getLoginItemSettings().openAtLogin, pluginsInstalled, isMounted: true, shortcutMap: this.settings.shortcuts }); if (this.settings.store.recordAudio) { this.getAudioDevices(); } }; getAudioDevices = async () => { const {getAudioDevices, getDefaultInputDevice} = this.remote.require('./utils/devices'); const {audioInputDeviceId} = this.settings.store; const {name: currentDefaultName} = getDefaultInputDevice() || {}; const audioDevices = await getAudioDevices(); const updates = { audioDevices: [ {name: `System Default${currentDefaultName ? ` (${currentDefaultName})` : ''}`, id: defaultInputDeviceId}, ...audioDevices ], audioInputDeviceId }; if (!audioDevices.some(device => device.id === audioInputDeviceId)) { updates.audioInputDeviceId = defaultInputDeviceId; this.settings.set('audioInputDeviceId', defaultInputDeviceId); } this.setState(updates); }; scrollIntoView = (tabId, pluginId) => { const plugin = document.querySelector(`#${tabId} #${pluginId}`).parentElement; plugin.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); }; openTarget = target => { const isInstalled = this.state.pluginsInstalled.some(plugin => plugin.name === target.name); const isFromNpm = this.state.pluginsFromNpm && this.state.pluginsFromNpm.some(plugin => plugin.name === target.name); if (target.action === 'install') { if (isInstalled) { this.scrollIntoView(this.state.tab, target.name); this.setState({category: 'plugins'}); } else if (isFromNpm) { this.scrollIntoView('discover', target.name); this.setState({category: 'plugins', tab: 'discover'}); const buttonIndex = this.remote.dialog.showMessageBoxSync(this.remote.getCurrentWindow(), { type: 'question', buttons: [ 'Install', 'Cancel' ], defaultId: 0, cancelId: 1, message: `Do you want to install the “${target.name}” plugin?` }); if (buttonIndex === 0) { this.install(target.name); } } else { this.setState({category: 'plugins'}); } } else if (target.action === 'configure' && isInstalled) { this.openPluginsConfig(target.name); } else { this.setState({category: 'plugins'}); } }; setNavigation = ({category, tab, target}) => { if (target) { if (this.state.isMounted) { this.openTarget(target); } else { this.setState({target}); } } else { this.setState({category, tab}); } }; fetchFromNpm = async () => { try { const plugins = await this.plugins.getFromNpm(); this.setState({ npmError: false, pluginsFromNpm: plugins.sort((a, b) => { if (a.isCompatible !== b.isCompatible) { return b.isCompatible - a.isCompatible; } return a.prettyName.localeCompare(b.prettyName); }) }); if (this.state.target) { this.openTarget(this.state.target); this.setState({target: undefined}); } } catch { this.setState({npmError: true}); } }; togglePlugin = plugin => { if (plugin.isInstalled) { this.uninstall(plugin.name); } else { this.install(plugin.name); } }; install = async name => { const {pluginsInstalled, pluginsFromNpm} = this.state; this.setState({pluginBeingInstalled: name}); const result = await this.plugins.install(name); if (result) { this.setState({ pluginBeingInstalled: undefined, pluginsFromNpm: pluginsFromNpm.filter(p => p.name !== name), pluginsInstalled: [result, ...pluginsInstalled].sort((a, b) => a.prettyName.localeCompare(b.prettyName)) }); } else { this.setState({ pluginBeingInstalled: undefined }); } }; uninstall = async name => { const {pluginsInstalled, pluginsFromNpm} = this.state; const onTransitionEnd = async () => { const plugin = await this.plugins.uninstall(name); this.setState({ pluginsInstalled: pluginsInstalled.filter(p => p.name !== name), pluginsFromNpm: [plugin, ...pluginsFromNpm].sort((a, b) => a.prettyName.localeCompare(b.prettyName)), pluginBeingUninstalled: null, onTransitionEnd: null }); }; this.setState({pluginBeingUninstalled: name, onTransitionEnd}); }; openPluginsConfig = async name => { this.track(`plugin/config/${name}`); this.scrollIntoView('installed', name); this.setState({category: 'plugins'}); this.setOverlay(true); await this.plugins.openPluginConfig(name); ipc.callMain('refresh-usage'); this.setOverlay(false); }; openPluginsFolder = () => electron.shell.openPath(this.plugins.pluginsDir); selectCategory = category => { this.setState({category}); }; selectTab = tab => { this.track(`preferences/tab/${tab}`); this.setState({tab}); }; toggleSetting = (setting, value) => { const newValue = value === undefined ? !this.state[setting] : value; if (!SETTINGS_ANALYTICS_BLACKLIST.includes(setting)) { this.track(`preferences/setting/${setting}/${newValue}`); } this.setState({[setting]: newValue}); this.settings.set(setting, newValue); }; toggleRecordAudio = async () => { const newValue = !this.state.recordAudio; this.track(`preferences/setting/recordAudio/${newValue}`); if (!newValue || await this.systemPermissions.ensureMicrophonePermissions()) { if (newValue) { try { await this.getAudioDevices(); } catch (error) { this.showError(error); } } this.setState({recordAudio: newValue}); this.settings.set('recordAudio', newValue); } }; toggleShortcuts = async () => { const setting = 'enableShortcuts'; const newValue = !this.state[setting]; this.toggleSetting(setting, newValue); await ipc.callMain('toggle-shortcuts', {enabled: newValue}); }; updateShortcut = async (setting, shortcut) => { try { await ipc.callMain('update-shortcut', {setting, shortcut}); this.setState({ shortcuts: { ...this.state.shortcuts, [setting]: shortcut } }); } catch (error) { console.warn('Error updating shortcut', error); } }; setOpenOnStartup = value => { const openOnStartup = typeof value === 'boolean' ? value : !this.state.openOnStartup; this.setState({openOnStartup}); this.remote.app.setLoginItemSettings({openAtLogin: openOnStartup}); }; pickKapturesDir = () => { const {dialog, getCurrentWindow} = this.remote; const directories = dialog.showOpenDialogSync(getCurrentWindow(), { properties: [ 'openDirectory', 'createDirectory' ] }); if (directories) { this.toggleSetting('kapturesDir', directories[0]); } }; setAudioInputDeviceId = id => { this.setState({audioInputDeviceId: id}); this.settings.set('audioInputDeviceId', id); }; } ================================================ FILE: renderer/hooks/dark-mode.tsx ================================================ import {useState, useEffect} from 'react'; const useDarkMode = () => { const {darkMode} = require('electron-util'); const [isDarkMode, setIsDarkMode] = useState(darkMode.isEnabled); useEffect(() => { return darkMode.onChange(() => { setIsDarkMode(darkMode.isEnabled); }); }, []); return isDarkMode; }; export default useDarkMode; ================================================ FILE: renderer/hooks/editor/use-conversion-id.tsx ================================================ import {CreateExportOptions} from 'common/types'; import {ipcRenderer} from 'electron-better-ipc'; import {createContext, PropsWithChildren, useContext, useMemo, useState} from 'react'; const ConversionIdContext = createContext<{ conversionId: string; setConversionId: (id: string) => void; startConversion: (options: CreateExportOptions) => Promise; }>(undefined); let savedConversionId: string; export const ConversionIdContextProvider = (props: PropsWithChildren>) => { const [conversionId, setConversionId] = useState(); const startConversion = async (options: CreateExportOptions) => { const id = await ipcRenderer.callMain('create-export', options); setConversionId(id); }; const updateConversionId = (id: string) => { savedConversionId = savedConversionId || id; setConversionId(id || savedConversionId); }; const value = useMemo(() => ({ conversionId, setConversionId: updateConversionId, startConversion }), [conversionId, setConversionId]); return ( {props.children} ); }; const useConversionIdContext = () => useContext(ConversionIdContext); export default useConversionIdContext; ================================================ FILE: renderer/hooks/editor/use-conversion.tsx ================================================ import {ExportsRemoteState} from 'common/types'; import createRemoteStateHook from 'hooks/use-remote-state'; const useConversion = createRemoteStateHook('exports'); export type UseConversion = ReturnType; export type UseConversionState = UseConversion['state']; export default useConversion; ================================================ FILE: renderer/hooks/editor/use-editor-options.tsx ================================================ import {EditorOptionsRemoteState} from 'common/types'; import createRemoteStateHook from 'hooks/use-remote-state'; const useEditorOptions = createRemoteStateHook('editor-options', { formats: [], editServices: [], fpsHistory: { gif: 60, mp4: 60, av1: 60, webm: 60, apng: 60, hevc: 60 } }); export type EditorOptionsState = ReturnType['state']; export default useEditorOptions; ================================================ FILE: renderer/hooks/editor/use-editor-window-state.tsx ================================================ import useWindowState from 'hooks/window-state'; import {EditorWindowState} from 'common/types'; const useEditorWindowState = () => useWindowState(); export default useEditorWindowState; ================================================ FILE: renderer/hooks/editor/use-share-plugins.tsx ================================================ import OptionsContainer from 'components/editor/options-container'; import {remote} from 'electron'; import {ipcRenderer} from 'electron-better-ipc'; import {useMemo} from 'react'; const useSharePlugins = () => { const { formats, format, sharePlugin, updateSharePlugin } = OptionsContainer.useContainer(); const menuOptions = useMemo(() => { const selectedFormat = formats.find(f => f.format === format); let onlyBuiltIn = true; const options = selectedFormat?.plugins?.map(plugin => { if (plugin.apps && plugin.apps.length > 0) { const subMenu = plugin.apps.map(app => ({ label: app.isDefault ? `${app.name} (default)` : app.name, type: 'radio', checked: sharePlugin.app?.url === app.url, value: { pluginName: plugin.pluginName, serviceTitle: plugin.title, app }, icon: remote.nativeImage.createFromDataURL(app.icon).resize({width: 16, height: 16}) })); if (plugin.apps[0].isDefault) { subMenu.splice(1, 0, {type: 'separator'} as any); } return { isBuiltIn: true, subMenu, value: { pluginName: plugin.pluginName, serviceTitle: plugin.title, app: plugin.apps[0] }, checked: sharePlugin.pluginName === plugin.pluginName, label: 'Open With…' }; } if (!plugin.pluginName.startsWith('_')) { onlyBuiltIn = false; } return { value: { pluginName: plugin.pluginName, serviceTitle: plugin.title }, checked: sharePlugin.pluginName === plugin.pluginName, label: plugin.title }; }); if (onlyBuiltIn) { options?.push({ separator: true } as any, { label: 'Get Plugins…', checked: false, click: () => { ipcRenderer.callMain('open-preferences', {category: 'plugins', tab: 'discover'}); } } as any); } return options ?? []; }, [formats, format, sharePlugin]); const label = sharePlugin?.app ? sharePlugin.app.name : sharePlugin?.serviceTitle; return {menuOptions, label, onChange: updateSharePlugin}; }; export default useSharePlugins; ================================================ FILE: renderer/hooks/editor/use-window-size.tsx ================================================ import {remote} from 'electron'; import {useEffect, useRef} from 'react'; import {resizeKeepingCenter} from 'utils/window'; const CONVERSION_WIDTH = 370; const CONVERSION_HEIGHT = 392; const DEFAULT_EDITOR_WIDTH = 768; const DEFAULT_EDITOR_HEIGHT = 480; export const useEditorWindowSizeEffect = (isConversionWindowState: boolean) => { const previousWindowSizeRef = useRef<{width: number; height: number}>(); useEffect(() => { if (!previousWindowSizeRef.current) { previousWindowSizeRef.current = { width: DEFAULT_EDITOR_WIDTH, height: DEFAULT_EDITOR_HEIGHT }; return; } const window = remote.getCurrentWindow(); const bounds = window.getBounds(); if (isConversionWindowState) { previousWindowSizeRef.current = { width: bounds.width, height: bounds.height }; window.setBounds(resizeKeepingCenter(bounds, {width: CONVERSION_WIDTH, height: CONVERSION_HEIGHT}), true); window.resizable = false; window.fullScreenable = false; } else { window.resizable = true; window.fullScreenable = true; window.setBounds(resizeKeepingCenter(bounds, previousWindowSizeRef.current), true); } }, [isConversionWindowState]); }; ================================================ FILE: renderer/hooks/exports/use-exports-list.tsx ================================================ import {ExportsListRemoteState} from 'common/types'; import createRemoteStateHook from 'hooks/use-remote-state'; const useExportsList = createRemoteStateHook('exports-list'); export type UseExportsList = ReturnType; export type UseExportsListState = UseExportsList['state']; export default useExportsList; ================================================ FILE: renderer/hooks/use-confirmation.tsx ================================================ import {useCallback} from 'react'; interface UseConfirmationOptions { message: string; detail?: string; confirmButtonText: string; cancelButtonText?: string; } export const useConfirmation = ( callback: () => void, options: UseConfirmationOptions ) => { return useCallback(() => { const {dialog, remote} = require('electron-util').api; const buttonIndex = dialog.showMessageBoxSync(remote.getCurrentWindow(), { type: 'question', buttons: [ options.confirmButtonText, options.cancelButtonText ?? 'Cancel' ], defaultId: 0, cancelId: 1, message: options.message, detail: options.detail }); if (buttonIndex === 0) { callback(); } }, [callback]); }; ================================================ FILE: renderer/hooks/use-current-window.tsx ================================================ import {remote} from 'electron'; export const useCurrentWindow = () => { return remote.getCurrentWindow(); }; ================================================ FILE: renderer/hooks/use-keyboard-action.tsx ================================================ import {DependencyList, useEffect, useMemo} from 'react'; export const useKeyboardAction = (keyOrFilter: string | ((key: string, eventType: string) => boolean), action: () => void, deps: DependencyList = []) => { const isArgFilter = typeof keyOrFilter === 'function'; const filter = useMemo(() => typeof keyOrFilter === 'function' ? keyOrFilter : (key: string) => key === keyOrFilter, [keyOrFilter]); useEffect(() => { const handler = (event: KeyboardEvent) => { if (filter(event.key, event.type)) { action(); } }; document.addEventListener('keyup', handler); if (isArgFilter) { document.addEventListener('keydown', handler); document.addEventListener('keypress', handler); } return () => { document.removeEventListener('keyup', handler); if (isArgFilter) { document.removeEventListener('keypress', handler); document.removeEventListener('keydown', handler); } }; }, [...deps, filter, action]); }; ================================================ FILE: renderer/hooks/use-remote-state.tsx ================================================ import {useState, useEffect, useRef} from 'react'; import {ipcRenderer} from 'electron-better-ipc'; import {RemoteState, RemoteStateHook} from '../common/types'; // TODO: Import these util exports from the `main/remote-states/utils` file once we figure out the correct TS configuration 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') }); const createRemoteStateHook = ( name: string, initialState?: Callback extends RemoteState ? State : never ): (id?: string) => RemoteStateHook => { const channelNames = getChannelNames(name); return (id?: string) => { const [state, setState] = useState(initialState); const [isLoading, setIsLoading] = useState(true); const actionsRef = useRef({}); useEffect(() => { const cleanup = ipcRenderer.answerMain(channelNames.stateUpdated, (data: {id?: string; state: any}) => { if (data.id === id) { setState(data.state); } }); (async () => { const actionKeys = (await ipcRenderer.callMain(channelNames.subscribe, id)); // eslint-disable-next-line unicorn/no-array-reduce const actions = actionKeys.reduce((acc, key) => ({ ...acc, [key]: async (...data: any) => ipcRenderer.callMain(channelNames.callAction, {key, data, id}) }), {}); const getState = async () => { const newState = (await ipcRenderer.callMain(channelNames.getState, id)); setState(newState); }; actionsRef.current = { ...actions, refreshState: getState }; await getState(); setIsLoading(false); })(); return cleanup; }, []); return { ...actionsRef.current, isLoading, state }; }; }; export default createRemoteStateHook; ================================================ FILE: renderer/hooks/use-show-window.tsx ================================================ import {useEffect} from 'react'; import {ipcRenderer} from 'electron-better-ipc'; export const useShowWindow = (show: boolean) => { useEffect(() => { if (show) { ipcRenderer.callMain('kap-window-mount'); } }, [show]); }; ================================================ FILE: renderer/hooks/window-state.tsx ================================================ import {createContext, useContext, useState, useEffect, ReactNode} from 'react'; import {ipcRenderer as ipc} from 'electron-better-ipc'; const WindowStateContext = createContext(undefined); export const WindowStateProvider = (props: {children: ReactNode}) => { const [windowState, setWindowState] = useState(); useEffect(() => { ipc.callMain('kap-window-state').then(setWindowState); return ipc.answerMain('kap-window-state', (newState: any) => { setWindowState(newState); }); }, []); return ( {props.children} ); }; // Should not be used directly // Each page should export its own typed hook // eslint-disable-next-line @typescript-eslint/comma-dangle const useWindowState = () => useContext(WindowStateContext); export default useWindowState; ================================================ FILE: renderer/next-env.d.ts ================================================ /// /// ================================================ FILE: renderer/next.config.js ================================================ const path = require('path'); module.exports = (nextConfig) => { return Object.assign({}, nextConfig, { webpack(config, options) { config.module.rules.push({ test: /\.+(js|jsx|mjs|ts|tsx)$/, loader: options.defaultLoaders.babel, include: [ path.join(__dirname, '..', 'main', 'common'), path.join(__dirname, '..', 'main', 'remote-states', 'use-remote-state.ts') ] }); config.target = 'electron-renderer'; config.devtool = 'cheap-module-source-map'; if (typeof nextConfig.webpack === 'function') { return nextConfig.webpack(config, options); } return config; } }) } ================================================ FILE: renderer/pages/_app.tsx ================================================ import {AppProps} from 'next/app'; import {useState, useEffect} from 'react'; import useDarkMode from '../hooks/dark-mode'; import GlobalStyles from '../utils/global-styles'; import SentryErrorBoundary from '../utils/sentry-error-boundary'; import {WindowStateProvider} from '../hooks/window-state'; import classNames from 'classnames'; const Kap = (props: AppProps) => { const [isMounted, setIsMounted] = useState(false); useEffect(() => { setIsMounted(true); }, []); if (!isMounted) { return null; } return ; }; const MainApp = ({Component, pageProps}: AppProps) => { const isDarkMode = useDarkMode(); const className = classNames('cover-window', {dark: isDarkMode}); return (
); }; export default Kap; ================================================ FILE: renderer/pages/config.js ================================================ import React from 'react'; import {Provider} from 'unstated'; import {ipcRenderer as ipc} from 'electron-better-ipc'; import {ConfigContainer} from '../containers'; import Config from '../components/config'; import WindowHeader from '../components/window-header'; const configContainer = new ConfigContainer(); export default class ConfigPage extends React.Component { state = {title: ''}; componentDidMount() { ipc.answerMain('plugin', pluginName => { configContainer.setPlugin(pluginName); this.setState({title: pluginName.replace(/^kap-/, '')}); }); ipc.answerMain('edit-service', ({pluginName, serviceTitle}) => { configContainer.setEditService(pluginName, serviceTitle); this.setState({title: serviceTitle}); }); } render() { const {title} = this.state; return (
); } } ================================================ FILE: renderer/pages/cropper.js ================================================ import electron from 'electron'; import React from 'react'; import {Provider} from 'unstated'; import Overlay from '../components/cropper/overlay'; import Cropper from '../components/cropper'; import ActionBar from '../components/action-bar'; import CursorContainer from '../containers/cursor'; import CropperContainer from '../containers/cropper'; import ActionBarContainer from '../containers/action-bar'; const cursorContainer = new CursorContainer(); const cropperContainer = new CropperContainer(); const actionBarContainer = new ActionBarContainer(); cropperContainer.bindCursor(cursorContainer); cropperContainer.bindActionBar(actionBarContainer); actionBarContainer.bindCursor(cursorContainer); actionBarContainer.bindCropper(cropperContainer); let lastRatioLockState = null; export default class CropperPage extends React.Component { remote = electron.remote || false; dev = false; constructor(props) { super(props); if (!electron.ipcRenderer) { return; } const {ipcRenderer, remote} = electron; ipcRenderer.on('display', (_, display) => { cropperContainer.setDisplay(display); actionBarContainer.setDisplay(display); }); ipcRenderer.on('select-app', (_, app) => { cropperContainer.selectApp(app); cropperContainer.setActive(true); }); ipcRenderer.on('blur', () => { cropperContainer.setActive(false); }); ipcRenderer.on('start-recording', () => { cropperContainer.setRecording(); }); const window = remote.getCurrentWindow(); window.on('focus', () => { cropperContainer.setActive(true); }); window.on('blur', event => { if (!event.defaultPrevented) { cropperContainer.setActive(false); } }); } componentDidMount() { document.addEventListener('keydown', this.handleKeyEvent); document.addEventListener('keyup', this.handleKeyEvent); } componentWillUnmount() { document.removeEventListener('keydown', this.handleKeyEvent); document.removeEventListener('keyup', this.handleKeyEvent); } handleKeyEvent = event => { switch (event.key) { case 'Escape': this.remote.getCurrentWindow().close(); break; case 'Shift': if (event.type === 'keydown' && !event.defaultPrevented) { lastRatioLockState = actionBarContainer.state.ratioLocked; actionBarContainer.toggleRatioLock(true); } else if (event.type === 'keyup' && lastRatioLockState !== null) { actionBarContainer.toggleRatioLock(lastRatioLockState); lastRatioLockState = null; } break; case 'Alt': cropperContainer.toggleResizeFromCenter(event.type === 'keydown'); break; case 'i': this.remote.getCurrentWindow().setIgnoreMouseEvents(true); this.dev = !this.dev; break; default: break; } }; render() { return (
); } } ================================================ FILE: renderer/pages/dialog.js ================================================ import Actions from '../components/dialog/actions'; import Icon from '../components/dialog/icon'; import Body from '../components/dialog/body'; import React, {useState, useEffect, useRef, useCallback} from 'react'; import {ipcRenderer as ipc} from 'electron-better-ipc'; let measureResolve; const Dialog = () => { const [data, setData] = useState(); const [measureSize, setMeasureSize] = useState(false); const [isDisabled, setIsDisabled] = useState(false); const container = useRef(null); const performAction = useCallback(async index => { if (!isDisabled || data.cancelId === index) { setIsDisabled(true); return ipc.callMain(`dialog-action-${data.id}`, index); } }, [data, isDisabled, setIsDisabled]); useEffect(() => { return ipc.answerMain('data', async newData => new Promise(resolve => { setData(newData); setMeasureSize(true); measureResolve = resolve; })); }, []); useEffect(() => { if (data) { setIsDisabled(false); } if (data && data.cancelId) { const handler = event => { if (event.code === 'Escape') { performAction(data.cancelId); } }; document.addEventListener('keydown', handler); return () => { document.removeEventListener('keydown', handler); }; } }, [data, performAction, isDisabled, setIsDisabled]); useEffect(() => { if (measureSize && measureResolve) { const {offsetWidth, offsetHeight} = container.current; measureResolve({width: offsetWidth, height: offsetHeight}); measureResolve = undefined; setMeasureSize(false); } }, [measureSize]); if (!data) { return null; } return (
); }; export default Dialog; ================================================ FILE: renderer/pages/editor.tsx ================================================ import Head from 'next/head'; // Import EditorPreview from '../components/editor/editor-preview'; import combineUnstatedContainers from '../utils/combine-unstated-containers'; import VideoMetadataContainer from '../components/editor/video-metadata-container'; import VideoTimeContainer from '../components/editor/video-time-container'; import VideoControlsContainer from '../components/editor/video-controls-container'; import OptionsContainer from '../components/editor/options-container'; import useEditorWindowState from 'hooks/editor/use-editor-window-state'; import {ConversionIdContextProvider} from 'hooks/editor/use-conversion-id'; import Editor from 'components/editor'; const ContainerProvider = combineUnstatedContainers([ OptionsContainer, VideoMetadataContainer, VideoTimeContainer, VideoControlsContainer ]) as any; const EditorPage = () => { const args = useEditorWindowState(); if (!args) { return null; } return (
); }; export default EditorPage; ================================================ FILE: renderer/pages/exports.tsx ================================================ import React from 'react'; import WindowHeader from '../components/window-header'; import Exports from '../components/exports'; const ExportsPage = () => (
); export default ExportsPage; ================================================ FILE: renderer/pages/preferences.js ================================================ import React from 'react'; import {Provider} from 'unstated'; import classNames from 'classnames'; import {ipcRenderer as ipc} from 'electron-better-ipc'; import PreferencesNavigation from '../components/preferences/navigation'; import WindowHeader from '../components/window-header'; import Categories from '../components/preferences/categories'; import PreferencesContainer from '../containers/preferences'; const preferencesContainer = new PreferencesContainer(); export default class PreferencesPage extends React.Component { state = {overlay: false}; componentDidMount() { ipc.answerMain('open-plugin-config', preferencesContainer.openPluginsConfig); ipc.answerMain('options', preferencesContainer.setNavigation); ipc.answerMain('mount', async () => preferencesContainer.mount(this.setOverlay)); } setOverlay = overlay => { this.setState({overlay}); }; render() { const {overlay} = this.state; const className = classNames('overlay', {active: overlay}); return (
); } } ================================================ FILE: renderer/tsconfig.eslint.json ================================================ { "extends": "./tsconfig.json", "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js" ] } ================================================ FILE: renderer/tsconfig.json ================================================ { "compilerOptions": { "target": "es2019", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "preserveSymlinks": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "downlevelIteration": true, "baseUrl": ".", "paths": { "utils/*": ["./utils/*"], "components/*": ["./components/*"], "containers/*": ["./containers/*"], "hooks/*": ["./hooks/*"], "common/*": ["./common/*"], "vectors": ["./vectors"] } }, "exclude": [ "node_modules", "common-remote-states" ], "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx" ] } ================================================ FILE: renderer/utils/combine-unstated-containers.tsx ================================================ import React, {FunctionComponent, PropsWithChildren} from 'react'; import {Container} from 'unstated-next'; type ContainerOrWithInitialState = Container | [Container, T]; const combineUnstatedContainers = (containers: ContainerOrWithInitialState[]) => ({children}: PropsWithChildren>) => { // eslint-disable-next-line unicorn/no-array-reduce return containers.reduce( (tree, ContainerOrWithInitialState) => { if (Array.isArray(ContainerOrWithInitialState)) { const [Container, initialState] = ContainerOrWithInitialState; return {tree}; } return {tree}; }, // @ts-expect-error children ); }; export default combineUnstatedContainers; ================================================ FILE: renderer/utils/format-time.js ================================================ import moment from 'moment'; const formatTime = (time, options) => { options = { showMilliseconds: false, ...options }; const durationFormatted = options.extra ? ` (${format(options.extra, options)})` : ''; return `${format(time, options)}${durationFormatted}`; }; const format = (time, {showMilliseconds} = {}) => { const formatString = `${time >= 60 * 60 ? 'hh:m' : ''}m:ss${showMilliseconds ? '.SS' : ''}`; return moment().startOf('day').millisecond(time * 1000).format(formatString); }; export default formatTime; ================================================ FILE: renderer/utils/global-styles.tsx ================================================ import {useState, useEffect, useMemo} from 'react'; import useDarkMode from '../hooks/dark-mode'; import {remote} from 'electron'; const GlobalStyles = () => { const [accentColor, setAccentColor] = useState(remote.systemPreferences.getAccentColor()); const isDarkMode = useDarkMode(); const systemColors = useMemo(() => { return systemColorNames .map(name => `--system-${name}: ${remote.systemPreferences.getColor(name as any)};`) .join('\n'); }, [isDarkMode]); const updateAccentColor = (_, accentColor) => { setAccentColor(accentColor); }; useEffect(() => { remote.systemPreferences.on('accent-color-changed', updateAccentColor); // Return () => { // api.systemPreferences.off('accent-color-changed', updateAccentColor); // }; }, []); return ( ); }; export default GlobalStyles; const systemColorNames = [ 'control-background', 'control', 'control-text', 'disabled-control-text', 'find-highlight', 'grid', 'header-text', 'highlight', 'keyboard-focus-indicator', 'label', 'link', 'placeholder-text', 'quaternary-label', 'scrubber-textured-background', 'secondary-label', 'selected-content-background', 'selected-control', 'selected-control-text', 'selected-menu-item-text', 'selected-text-background', 'selected-text', 'separator', 'shadow', 'tertiary-label', 'text-background', 'text', 'under-page-background', 'unemphasized-selected-content-background', 'unemphasized-selected-text-background', 'unemphasized-selected-text', 'window-background', 'window-frame-text' ]; ================================================ FILE: renderer/utils/inputs.js ================================================ import electron from 'electron'; import _ from 'lodash'; let screenWidth = 0; let screenHeight = 0; export const setScreenSize = (width, height) => { screenWidth = width; screenHeight = height; }; const {remote} = electron; const debounceTimeout = 500; export const minWidth = 20; export const minHeight = 20; export const shake = (element, {className = 'shake'} = {}) => { element.classList.add(className); element.addEventListener('animationend', () => { element.classList.remove(className); }, {once: true}); return true; }; export const resizeTo = (bounds, target) => { const {x, y} = bounds; return { width: target.width, x: Math.min(x, screenWidth - target.width), height: target.height, y: Math.min(y, screenHeight - target.height) }; }; const handleWidthInput = _.debounce(({ bounds, setBounds, ratioLocked, ratio, value, widthInput, heightInput, ignoreEmpty = true }) => { const target = {}; if (value === '' && ignoreEmpty) { return; } if (/^\d+$/.test(value)) { const integer = Number.parseInt(value, 10); target.width = Math.max(minWidth, Math.min(screenWidth, integer)); if (target.width !== integer) { shake(widthInput.current); } if (ratioLocked) { const computedHeight = Math.ceil(target.width * ratio[1] / ratio[0]); target.height = Math.max(minHeight, Math.min(screenHeight, computedHeight)); if (target.height !== computedHeight) { shake(widthInput.current); shake(heightInput.current); target.width = Math.ceil(target.height * ratio[0] / ratio[1]); } } else if (bounds.height) { target.height = bounds.height; } else { target.height = minHeight; } setBounds(resizeTo(bounds, target)); } else { // If it's not an integer keep last valid value shake(widthInput.current); setBounds(); } }, debounceTimeout); const handleHeightInput = _.debounce(({ bounds, setBounds, ratioLocked, ratio, value, widthInput, heightInput, ignoreEmpty = true }) => { const target = {}; if (value === '' && ignoreEmpty) { return; } if (/^\d+$/.test(value)) { const integer = Number.parseInt(value, 10); target.height = Math.max(minHeight, Math.min(screenHeight, integer)); if (target.height !== integer) { shake(heightInput.current); } if (ratioLocked) { const computedWidth = Math.ceil(target.height * ratio[0] / ratio[1]); target.width = Math.max(minWidth, Math.min(screenWidth, computedWidth)); if (target.width !== computedWidth) { shake(widthInput.current); shake(heightInput.current); target.height = Math.ceil(target.width * ratio[1] / ratio[0]); } } else if (bounds.width) { target.width = bounds.width; } else { target.width = minWidth; } setBounds(resizeTo(bounds, target)); } else { // If it's not an integer keep last valid value shake(heightInput.current); setBounds(); } }, debounceTimeout); export const RATIOS = [ '16:9', '5:4', '5:3', '4:3', '3:2', '1:1' ]; const buildAspectRatioMenu = ({setRatio, ratio}) => { if (!remote) { return; } const {Menu, MenuItem} = remote; const selectedRatio = ratio.join(':'); const menu = new Menu(); for (const r of RATIOS) { menu.append( new MenuItem({ label: r, type: 'radio', checked: r === selectedRatio, click: () => setRatio(r.split(':').map(d => Number.parseInt(d, 10))) }) ); } const customOption = RATIOS.includes(selectedRatio) ? { label: 'Custom', type: 'radio', checked: false, enabled: false } : { label: `Custom ${selectedRatio}`, type: 'radio', checked: true }; menu.append(new MenuItem(customOption)); return menu; }; const handleInputKeyPress = (onChange, min, max) => event => { if (event.key === 'Enter') { return onChange(event, {ignoreEmpty: false}); } // Don't let shift key lock aspect ratio if (event.key === 'Shift') { event.stopPropagation(); } const multiplier = event.shiftKey ? 10 : 1; const parsedValue = Number.parseInt(event.currentTarget.value, 10); if (Number.isNaN(parsedValue)) { return; } // Fake an onChange event if (event.key === 'ArrowUp') { event.currentTarget.value = `${Math.min(parsedValue + multiplier, max)}`; onChange(event); } else if (event.key === 'ArrowDown') { event.currentTarget.value = `${Math.max(parsedValue - multiplier, min)}`; onChange(event); } }; const handleKeyboardActivation = (onClick, {isMenu} = {}) => event => { if ( (isMenu && event.key === 'ArrowDown') || (!isMenu && ['Enter', ' '].includes(event.key)) ) { event.preventDefault(); if (onClick) { onClick(event); } } }; export { handleWidthInput, handleHeightInput, buildAspectRatioMenu, handleInputKeyPress, handleKeyboardActivation }; ================================================ FILE: renderer/utils/sentry-error-boundary.tsx ================================================ import React from 'react'; import * as Sentry from '@sentry/browser'; import electron from 'electron'; import type {api as Api, is as Is} from 'electron-util'; const SENTRY_PUBLIC_DSN = 'https://2dffdbd619f34418817f4db3309299ce@sentry.io/255536'; class SentryErrorBoundary extends React.Component<{children: React.ReactNode}> { constructor(props) { super(props); const {settings} = electron.remote.require('./common/settings'); // Done in-line because this is used in _app const {is, api} = require('electron-util') as { api: typeof Api; is: typeof Is; }; if (!is.development && settings.get('allowAnalytics')) { const release = `${api.app.name}@${api.app.getVersion()}`.toLowerCase(); Sentry.init({dsn: SENTRY_PUBLIC_DSN, release}); } } componentDidCatch(error, errorInfo) { console.log(error, errorInfo); Sentry.configureScope(scope => { for (const [key, value] of Object.entries(errorInfo)) { scope.setExtra(key, value); } }); Sentry.captureException(error); // This is needed to render errors correctly in development / production super.componentDidCatch(error, errorInfo); } render() { return this.props.children; } } export default SentryErrorBoundary; ================================================ FILE: renderer/utils/window.ts ================================================ export const resizeKeepingCenter = ( bounds: Electron.Rectangle, newSize: {width: number; height: number} ): Electron.Rectangle => { const cx = Math.round(bounds.x + (bounds.width / 2)); const cy = Math.round(bounds.y + (bounds.height / 2)); return { x: Math.round(cx - (newSize.width / 2)), y: Math.round(cy - (newSize.height / 2)), width: newSize.width, height: newSize.height }; }; ================================================ FILE: renderer/vectors/applications.js ================================================ import React from 'react'; import Svg from './svg'; // eslint-disable-next-line unicorn/prevent-abbreviations const ApplicationsIcon = props => ( ); export default ApplicationsIcon; ================================================ FILE: renderer/vectors/back-plain.tsx ================================================ import React, {FunctionComponent} from 'react'; import Svg, {SvgProps} from './svg'; const BackPlainIcon: FunctionComponent = props => ( ); export default BackPlainIcon; ================================================ FILE: renderer/vectors/back.js ================================================ import React from 'react'; import Svg from './svg'; const BackIcon = props => ( ); export default BackIcon; ================================================ FILE: renderer/vectors/cancel.js ================================================ import React from 'react'; import Svg from './svg'; const CancelIcon = props => ( ); export default CancelIcon; ================================================ FILE: renderer/vectors/crop.js ================================================ import React from 'react'; import Svg from './svg'; const CropIcon = props => ( ); export default CropIcon; ================================================ FILE: renderer/vectors/dropdown-arrow.js ================================================ import React from 'react'; import Svg from './svg'; const DropdownArrowIcon = props => ( ); export default DropdownArrowIcon; ================================================ FILE: renderer/vectors/edit.js ================================================ import React from 'react'; import Svg from './svg'; const EditIcon = props => ( ); export default EditIcon; ================================================ FILE: renderer/vectors/error.js ================================================ import React from 'react'; import Svg from './svg'; const ErrorIcon = props => ( ); export default ErrorIcon; ================================================ FILE: renderer/vectors/exit-fullscreen.js ================================================ import React from 'react'; import Svg from './svg'; const ExitFullscreenIcon = props => ( ); export default ExitFullscreenIcon; ================================================ FILE: renderer/vectors/fullscreen.js ================================================ import React from 'react'; import Svg from './svg'; const FullscrenIcon = props => ( ); export default FullscrenIcon; ================================================ FILE: renderer/vectors/gear.js ================================================ import React from 'react'; import Svg from './svg'; const GearIcon = props => ( ); export default GearIcon; ================================================ FILE: renderer/vectors/help.js ================================================ import React from 'react'; import Svg from './svg'; const HelpIcon = props => ( ); export default HelpIcon; ================================================ FILE: renderer/vectors/index.js ================================================ import ApplicationsIcon from './applications'; import BackIcon from './back'; import CropIcon from './crop'; import DropdownArrowIcon from './dropdown-arrow'; import FullscreenIcon from './fullscreen'; import LinkIcon from './link'; import SwapIcon from './swap'; import ExitFullscreenIcon from './exit-fullscreen'; import SettingsIcon from './settings'; import TuneIcon from './tune'; import PluginsIcon from './plugins'; import GearIcon from './gear'; import SpinnerIcon from './spinner'; import MoreIcon from './more'; import PlayIcon from './play'; import PauseIcon from './pause'; import VolumeHighIcon from './volume-high'; import VolumeOffIcon from './volume-off'; import CancelIcon from './cancel'; import TooltipIcon from './tooltip'; import EditIcon from './edit'; import ErrorIcon from './error'; import OpenConfigIcon from './open-config'; import OpenOnGithubIcon from './open-on-github'; import HelpIcon from './help'; import BackPlainIcon from './back-plain'; export { ApplicationsIcon, BackIcon, CropIcon, DropdownArrowIcon, FullscreenIcon, LinkIcon, SwapIcon, ExitFullscreenIcon, SettingsIcon, TuneIcon, PluginsIcon, GearIcon, SpinnerIcon, MoreIcon, PlayIcon, PauseIcon, VolumeHighIcon, VolumeOffIcon, CancelIcon, TooltipIcon, EditIcon, ErrorIcon, OpenConfigIcon, OpenOnGithubIcon, HelpIcon, BackPlainIcon }; ================================================ FILE: renderer/vectors/link.js ================================================ import React from 'react'; import Svg from './svg'; const LinkIcon = props => ( ); export default LinkIcon; ================================================ FILE: renderer/vectors/more.js ================================================ import React from 'react'; import Svg from './svg'; const MoreIcon = props => ( ); export default MoreIcon; ================================================ FILE: renderer/vectors/open-config.js ================================================ import React from 'react'; import Svg from './svg'; const OpenConfigIcon = props => ( ); export default OpenConfigIcon; ================================================ FILE: renderer/vectors/open-on-github.js ================================================ import React from 'react'; import Svg from './svg'; const OpenOnGithubIcon = props => ( ); export default OpenOnGithubIcon; ================================================ FILE: renderer/vectors/pause.js ================================================ import React from 'react'; import Svg from './svg'; const PauseIcon = props => ( ); export default PauseIcon; ================================================ FILE: renderer/vectors/play.js ================================================ import React from 'react'; import Svg from './svg'; const PlayIcon = props => ( ); export default PlayIcon; ================================================ FILE: renderer/vectors/plugins.js ================================================ import React from 'react'; import Svg from './svg'; const PluginsIcon = props => ( ); export default PluginsIcon; ================================================ FILE: renderer/vectors/settings.js ================================================ import React from 'react'; import Svg from './svg'; const SettingsIcon = props => ( ); export default SettingsIcon; ================================================ FILE: renderer/vectors/spinner.js ================================================ import PropTypes from 'prop-types'; import React from 'react'; const SpinnerIcon = ({stroke = 'var(--kap)'}) => ( ); SpinnerIcon.propTypes = { stroke: PropTypes.string }; export default SpinnerIcon; ================================================ FILE: renderer/vectors/svg.tsx ================================================ import React, {FunctionComponent} from 'react'; import classNames from 'classnames'; import {handleKeyboardActivation} from '../utils/inputs'; const defaultProps: SvgProps = { fill: 'var(--icon-color)', activeFill: 'var(--kap)', hoverFill: 'var(--icon-hover-color)', size: '24px', active: false, viewBox: '0 0 24 24', tabIndex: -1 }; const stopPropagation = event => { event.stopPropagation(); }; const Svg: FunctionComponent = props => { const { fill, size, activeFill, hoverFill, active, onClick, children, viewBox, shadow, tabIndex, isMenu } = { ...defaultProps, ...props }; const className = classNames({active, shadow, focusable: tabIndex >= 0}); return (
= 0 ? handleKeyboardActivation(onClick, {isMenu}) : undefined}> {children}
); }; export interface SvgProps { fill?: string; size?: string; activeFill?: string; hoverFill?: string; active?: boolean; viewBox?: string; onClick?: () => void; shadow?: boolean; tabIndex?: number; isMenu?: boolean; } export default Svg; ================================================ FILE: renderer/vectors/swap.js ================================================ import React from 'react'; import Svg from './svg'; const SwapIcon = props => ( ); export default SwapIcon; ================================================ FILE: renderer/vectors/tooltip.js ================================================ import React from 'react'; import Svg from './svg'; const TooltipIcon = props => ( ); export default TooltipIcon; ================================================ FILE: renderer/vectors/tune.js ================================================ import React from 'react'; import Svg from './svg'; const TuneIcon = props => ( ); export default TuneIcon; ================================================ FILE: renderer/vectors/volume-high.js ================================================ import React from 'react'; import Svg from './svg'; const VolumeHighIcon = props => ( ); export default VolumeHighIcon; ================================================ FILE: renderer/vectors/volume-off.js ================================================ import React from 'react'; import Svg from './svg'; const VolumeOffIcon = props => ( ); export default VolumeOffIcon; ================================================ FILE: test/convert.ts ================================================ import {serial as testAny, TestInterface} from 'ava'; import fs from 'fs'; import path from 'path'; import sinon from 'sinon'; import uniqueString from 'unique-string'; const test = testAny as TestInterface<{outputPath: string}>; import {getVideoMetadata} from './helpers/video-utils'; import {almostEquals} from './helpers/assertions'; import {getFormatExtension} from '../main/common/constants'; import {Except, SetOptional} from 'type-fest'; import {mockImport} from './helpers/mocks'; import {Format} from '../main/common/types'; const getRandomFileName = (ext: Format = Format.mp4) => `${uniqueString()}.${getFormatExtension(ext)}`; const input = path.resolve(__dirname, 'fixtures', 'input.mp4'); const retinaInput = path.resolve(__dirname, 'fixtures', 'input@2x.mp4'); mockImport('../common/analytics', 'analytics'); mockImport('../plugins/service-context', 'service-context'); mockImport('../plugins', 'plugins'); const {settings} = mockImport('../common/settings', 'settings'); import {convertTo} from '../main/converters'; import {ConvertOptions} from '../main/converters/utils'; test.afterEach.always(t => { if (t.context.outputPath && fs.existsSync(t.context.outputPath)) { fs.unlinkSync(t.context.outputPath); } }); const convert = async (format: Format, options: SetOptional, 'onCancel' | 'onProgress' | 'shouldMute'>) => { return convertTo(format, { defaultFileName: getRandomFileName(format), onProgress: sinon.fake(), onCancel: sinon.fake(), shouldMute: true, ...options }); }; // MP4 test('mp4: retina with sound', async t => { const onProgress = sinon.fake(); t.context.outputPath = await convert(Format.mp4, { shouldMute: false, inputPath: retinaInput, fps: 39, width: 469, height: 839, startTime: 30, endTime: 43.5, shouldCrop: true, onProgress }); const meta = await getVideoMetadata(t.context.outputPath); // Makes dimensions even t.is(meta.size.width, 470); t.is(meta.size.height, 840); t.is(meta.fps, 39); t.true(almostEquals(meta.duration, 13.5)); t.is(meta.encoding, 'h264'); t.true(meta.hasAudio); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number)); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number, sinon.match.string)); }); test('mp4: retina without sound', async t => { t.context.outputPath = await convert(Format.mp4, { shouldMute: true, inputPath: retinaInput, fps: 10, width: 46, height: 83, startTime: 0, endTime: 5, shouldCrop: true }); const meta = await getVideoMetadata(t.context.outputPath); t.false(meta.hasAudio); }); test('mp4: non-retina', async t => { t.context.outputPath = await convert(Format.mp4, { shouldMute: false, inputPath: input, fps: 30, width: 255, height: 143, startTime: 11.5, endTime: 27, // Should resize even though this is false, because dimensions are odd shouldCrop: false }); const meta = await getVideoMetadata(t.context.outputPath); // Makes dimensions even t.is(meta.size.width, 256); t.is(meta.size.height, 144); t.is(meta.fps, 30); t.true(almostEquals(meta.duration, 15.5)); t.is(meta.encoding, 'h264'); t.false(meta.hasAudio); }); // WEBM test('webm: retina with sound', async t => { const onProgress = sinon.fake(); t.context.outputPath = await convert(Format.webm, { shouldMute: false, inputPath: retinaInput, fps: 39, width: 469, height: 839, startTime: 30, endTime: 43.5, shouldCrop: true, onProgress }); const meta = await getVideoMetadata(t.context.outputPath); t.is(meta.size.width, 470); t.is(meta.size.height, 840); t.is(meta.fps, 39); t.true(almostEquals(meta.duration, 13.5)); t.is(meta.encoding, 'vp9'); t.true(meta.hasAudio); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number)); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number, sinon.match.string)); }); test('webm: retina without sound', async t => { t.context.outputPath = await convert(Format.webm, { shouldMute: true, inputPath: retinaInput, fps: 10, width: 46, height: 83, startTime: 0, endTime: 5, shouldCrop: true }); const meta = await getVideoMetadata(t.context.outputPath); t.false(meta.hasAudio); }); test('webm: non-retina', async t => { t.context.outputPath = await convert(Format.webm, { shouldMute: false, inputPath: input, fps: 30, width: 255, height: 143, startTime: 11.5, endTime: 27, shouldCrop: true }); const meta = await getVideoMetadata(t.context.outputPath); t.is(meta.size.width, 256); t.is(meta.size.height, 144); t.is(meta.fps, 30); t.true(almostEquals(meta.duration, 15.5)); t.is(meta.encoding, 'vp9'); t.false(meta.hasAudio); }); // APNG test('apng: retina', async t => { const onProgress = sinon.fake(); t.context.outputPath = await convert(Format.apng, { shouldMute: false, inputPath: retinaInput, fps: 15, width: 469, height: 839, startTime: 30, endTime: 43.5, shouldCrop: true, onProgress }); const meta = await getVideoMetadata(t.context.outputPath); t.is(meta.size.width, 469); t.is(meta.size.height, 839); t.is(meta.fps, 15); t.is(meta.encoding, 'apng'); t.false(meta.hasAudio); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number)); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number, sinon.match.string)); }); test('apng: non-retina', async t => { t.context.outputPath = await convert(Format.apng, { shouldMute: false, inputPath: input, fps: 15, width: 255, height: 143, startTime: 11.5, endTime: 27, shouldCrop: true }); const meta = await getVideoMetadata(t.context.outputPath); t.is(meta.size.width, 255); t.is(meta.size.height, 143); t.is(meta.fps, 15); t.is(meta.encoding, 'apng'); t.false(meta.hasAudio); }); // GIF test('gif: retina', async t => { const onProgress = sinon.fake(); t.context.outputPath = await convert(Format.gif, { shouldMute: false, inputPath: retinaInput, fps: 10, width: 236, height: 420, startTime: 0, endTime: 8.5, shouldCrop: true, onProgress }); const meta = await getVideoMetadata(t.context.outputPath); t.is(meta.size.width, 236); t.is(meta.size.height, 420); t.is(meta.fps, 10); t.true(almostEquals(meta.duration, 8.5)); t.is(meta.encoding, 'gif'); t.false(meta.hasAudio); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number)); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number, sinon.match.string)); }); test('gif: non-retina', async t => { t.context.outputPath = await convert(Format.gif, { shouldMute: false, inputPath: input, fps: 15, width: 255, height: 143, startTime: 11.5, endTime: 27, shouldCrop: true }); const meta = await getVideoMetadata(t.context.outputPath); t.is(meta.size.width, 255); t.is(meta.size.height, 143); t.is(meta.fps, 15); t.true(almostEquals(meta.duration, 15.5)); t.is(meta.encoding, 'gif'); t.false(meta.hasAudio); }); test('gif: lossy', async t => { settings.setMock('lossyCompression', false); const regular = await convert(Format.gif, { inputPath: input, fps: 20, width: 510, height: 286, startTime: 1, endTime: 10, shouldCrop: true }); settings.setMock('lossyCompression', true); const lossy = await convert(Format.gif, { inputPath: input, fps: 20, width: 510, height: 286, startTime: 1, endTime: 10, shouldCrop: true }); t.true( fs.statSync(regular).size >= fs.statSync(lossy).size ); }); // AV1 test('av1: retina with sound', async t => { const onProgress = sinon.fake(); t.context.outputPath = await convert(Format.av1, { shouldMute: false, inputPath: retinaInput, fps: 15, width: 235, height: 420, startTime: 30, endTime: 35.5, shouldCrop: true, onProgress }); const meta = await getVideoMetadata(t.context.outputPath); // Makes dimensions even t.is(meta.size.width, 236); t.is(meta.size.height, 420); t.is(meta.fps, 15); t.true(almostEquals(meta.duration, 5.5)); t.is(meta.encoding, 'av1'); t.true(meta.hasAudio); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number)); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number, sinon.match.string)); }); test('av1: retina without sound', async t => { t.context.outputPath = await convert(Format.av1, { shouldMute: true, inputPath: retinaInput, fps: 10, width: 100, height: 200, startTime: 0, endTime: 4, shouldCrop: true }); const meta = await getVideoMetadata(t.context.outputPath); t.false(meta.hasAudio); }); test('av1: non-retina', async t => { t.context.outputPath = await convert(Format.av1, { shouldMute: false, inputPath: input, fps: 10, width: 255, height: 143, startTime: 11.5, endTime: 16, shouldCrop: true }); const meta = await getVideoMetadata(t.context.outputPath); // Makes dimensions even t.is(meta.size.width, 256); t.is(meta.size.height, 144); t.is(meta.fps, 10); t.true(almostEquals(meta.duration, 4.5)); t.is(meta.encoding, 'av1'); t.false(meta.hasAudio); }); // HEVC test('HEVC: retina', async t => { const onProgress = sinon.fake(); t.context.outputPath = await convert(Format.hevc, { shouldMute: true, inputPath: retinaInput, fps: 15, width: 469, height: 839, startTime: 30, endTime: 43.5, shouldCrop: true, onProgress }); const meta = await getVideoMetadata(t.context.outputPath); // Makes dimensions even t.is(meta.size.width, 470); t.is(meta.size.height, 840); t.is(meta.fps, 15); t.is(meta.encoding, 'hevc'); t.false(meta.hasAudio); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number)); t.true(onProgress.calledWithMatch(sinon.match.string, sinon.match.number, sinon.match.string)); }); test('HEVC: non-retina', async t => { t.context.outputPath = await convert(Format.hevc, { shouldMute: true, inputPath: input, fps: 15, width: 255, height: 143, startTime: 11.5, endTime: 27, shouldCrop: true }); const meta = await getVideoMetadata(t.context.outputPath); // Makes dimensions even t.is(meta.size.width, 256); t.is(meta.size.height, 144); t.is(meta.fps, 15); t.is(meta.encoding, 'hevc'); t.false(meta.hasAudio); }); ================================================ FILE: test/helpers/assertions.ts ================================================ export const almostEquals = (actual: number, expected: number, threshold = 0.5) => { return Math.abs(actual - expected) <= threshold ? true : ` Actual: ${actual} Expected: ${expected} Threshold: ${threshold} Diff: ${Math.abs(actual - expected)} `; }; ================================================ FILE: test/helpers/mocks.ts ================================================ import moduleAlias from 'module-alias'; import path from 'path'; import fs from 'fs'; export const mockModule = (name: string) => { const mockModulePathTypescript = path.resolve(__dirname, '..', 'mocks', `${name}.ts`); const mockModulePath = path.resolve(__dirname, '..', 'mocks', `${name}.js`); const mockPath = [ mockModulePathTypescript, mockModulePath ].find(p => fs.existsSync(p)); if (!mockPath) { throw new Error(`Missing mock implementation at ${mockModulePath}`.replace('js', '(ts|js)')); } moduleAlias.addAlias(name, mockPath); return require(mockPath); }; export const mockImport = (importPath: string, mock: string) => { const mockModulePathTypescript = path.resolve(__dirname, '..', 'mocks', `${mock}.ts`); const mockModulePath = path.resolve(__dirname, '..', 'mocks', `${mock}.js`); const mockPath = [ mockModulePathTypescript, mockModulePath ].find(p => fs.existsSync(p)); if (!mockPath) { throw new Error(`Missing mock implementation at ${mockModulePath}`.replace('js', '(ts|js)')); } moduleAlias.addAlias(importPath, mockPath); return require(mockPath); }; ================================================ FILE: test/helpers/video-utils.ts ================================================ import moment from 'moment'; import execa from 'execa'; const ffmpegPath = require('ffmpeg-static'); const getDuration = (text: string): number => { const durationString = /Duration: ([\d:.]*)/.exec(text)?.[1]; return moment.duration(durationString).asSeconds(); }; const getEncoding = (text: string) => /Stream.*Video: (.*?)[, ]/.exec(text)?.[1]; const getFps = (text: string) => { const fpsString = /([\d.]*) fps/.exec(text)?.[1]; return Number.parseFloat(fpsString!); }; const getSize = (text: string) => { const sizeText = /Video:.*?, (\d*x\d*)/.exec(text)?.[1]!; const parts = sizeText.split('x'); return { width: Number.parseFloat(parts[0]), height: Number.parseFloat(parts[1]) }; }; const getHasAudio = (text: string) => /Stream #.*: Audio/.test(text); // @ts-expect-error export const getVideoMetadata = async (path: string): Promise<{ duration: number; encoding: string; fps: number; size: {width: number; height: number}; hasAudio: boolean; }> => { try { await execa(ffmpegPath, ['-i', path]); } catch (error) { const {stderr} = error as any; return { duration: getDuration(stderr), encoding: getEncoding(stderr)!, fps: getFps(stderr)!, size: getSize(stderr) as {width: number; height: number}, hasAudio: getHasAudio(stderr)! }; } }; ================================================ FILE: test/mocks/analytics.ts ================================================ export const initializeAnalytics = () => {}; export const track = () => {}; ================================================ FILE: test/mocks/dialog.ts ================================================ import sinon from 'sinon'; let dialogState: any; let dialogResolve: any; let waitForDialogResolve: any; export const showDialog = sinon.fake(async (options: any) => new Promise(resolve => { dialogState = options; dialogResolve = resolve; if (waitForDialogResolve) { waitForDialogResolve(options); } waitForDialogResolve = undefined; })); const resolve = (result: any) => { if (dialogResolve) { dialogResolve(result); } dialogResolve = undefined; dialogState = undefined; }; export const fakeAction = async (index: any) => { const button = dialogState.buttons[index]; const action = button?.action; let wasCalled = false; if (action) { await action(resolve, (newState: any) => { wasCalled = true; dialogState = newState; }); if (!wasCalled) { resolve(index); } } else { resolve(index); } }; export const getCurrentState = () => dialogState; export const waitForDialog = async () => new Promise(resolve => { waitForDialogResolve = resolve; }); ================================================ FILE: test/mocks/electron-store.ts ================================================ import sinon from 'sinon'; const mocks: Record = {}; const store: Record = {}; const getMock = sinon.fake( (key: string, defaultValue: any) => mocks[key] ?? store[key] ?? defaultValue ); const setMock = sinon.fake( (key: string, value: any) => { store[key] = value; } ); const deleteMock = sinon.fake( (key: string) => { delete store[key]; } ); const clearMock = sinon.fake( () => { for (const key of Object.keys(store)) { delete store[key]; } } ); export default class Store { get = getMock; set = setMock; delete = deleteMock; clear = clearMock; get store() { return { ...store, ...mocks }; } static mockGet = (key: string, result: any) => { mocks[key] = result; }; static clearMocks = () => { for (const key of Object.keys(mocks)) { delete mocks[key]; } }; static mocks = { get: getMock, set: setMock, delete: deleteMock, clear: clearMock }; } ================================================ FILE: test/mocks/electron.ts ================================================ import sinon from 'sinon'; import tempy from 'tempy'; import path from 'path'; const temporaryDir = tempy.directory(); process.env.TZ = 'America/New_York'; (process.versions as any).chrome = ''; export const app = { getPath: (name: string) => path.resolve(temporaryDir, name), isPackaged: false, getVersion: '' }; export const shell = { showItemInFolder: sinon.fake() }; export const clipboard = { writeText: sinon.fake() }; export const remote = {}; ================================================ FILE: test/mocks/plugins.ts ================================================ import sinon from 'sinon'; import {Mutable, PartialDeep} from 'type-fest'; import type {Plugins} from '../../main/plugins'; export const plugins: PartialDeep> = { recordingPlugins: [], sharePlugins: [], editPlugins: [] }; ================================================ FILE: test/mocks/sentry.ts ================================================ import sinon from 'sinon'; export const isSentryEnabled = true; export default { captureException: sinon.fake() }; ================================================ FILE: test/mocks/service-context.ts ================================================ export class ShareServiceContext {} export class RecordServiceContext {} export class EditServiceContext {} ================================================ FILE: test/mocks/settings.ts ================================================ import sinon from 'sinon'; const mocks: Record = {}; const mockGet = sinon.fake((key: string, defaultValue: any) => mocks[key] || defaultValue); export const settings = { get: mockGet, set: sinon.fake(), delete: sinon.fake(), setMock: (key: string, value: any) => { mocks[key] = value; } }; ================================================ FILE: test/mocks/video.ts ================================================ import sinon from 'sinon'; const mocks = { open: sinon.fake(), constructor: sinon.fake(), getOrCreate: sinon.fake(() => new Video()) }; export default class Video { static getOrCreate = mocks.getOrCreate; openEditorWindow = mocks.open; constructor(...args: any[]) { mocks.constructor(...args); } static mocks = mocks; } ================================================ FILE: test/mocks/window-manager.ts ================================================ import sinon from 'sinon'; import {SetOptional} from 'type-fest'; import type {WindowManager} from '../../main/windows/manager'; import * as dialogManager from './dialog'; export class MockWindowManager implements SetOptional< WindowManager, 'setEditor' | 'setCropper' | 'setConfig' | 'setDialog' | 'setExports' | 'setPreferences' > { editor = { open: sinon.fake(), areAnyBlocking: () => false }; dialog = { open: dialogManager.showDialog, ...dialogManager }; } export const windowManager = new MockWindowManager(); ================================================ FILE: test/recording-history.ts ================================================ import {serial as testAny, TestInterface} from 'ava'; import tempy from 'tempy'; import fs from 'fs'; import sinon, {SinonFakeTimers} from 'sinon'; import path from 'path'; import type {MockWindowManager} from './mocks/window-manager'; const test = testAny as TestInterface<{ now: Date; clock: SinonFakeTimers; paths?: string[]; }>; import {mockImport, mockModule} from './helpers/mocks'; mockImport('./windows/manager', 'window-manager'); mockImport('./plugins', 'plugins'); mockImport('./utils/sentry', 'sentry'); mockImport('../common/analytics', 'analytics'); import {shell} from './mocks/electron'; import * as dialog from './mocks/dialog'; import {plugins} from './mocks/plugins'; import Sentry from './mocks/sentry'; import {windowManager} from './mocks/window-manager'; import { recordingHistory, getPastRecordings, hasActiveRecording, addRecording, setCurrentRecording, updatePluginState, stopCurrentRecording, cleanPastRecordings, PastRecording } from '../main/recording-history'; import type {Video} from '../main/video'; const incomplete = path.resolve(__dirname, 'fixtures', 'incomplete.mp4'); const corrupt = path.resolve(__dirname, 'fixtures', 'corrupt.mp4'); test.before(t => { t.context.now = new Date('2020-07-21T15:27:26.564Z'); t.context.clock = sinon.useFakeTimers(t.context.now.getTime()); }); test.after(t => { t.context.clock.restore(); }); test.beforeEach(() => { recordingHistory.clear(); }); test.afterEach.always(t => { if (t.context.paths) { for (const path of t.context.paths) { if (fs.existsSync(path)) { fs.unlinkSync(path); } } } }); test('`getPastRecordings()`', t => { const existingPath = tempy.file({extension: 'mp4'}); const missingPath = tempy.file({extension: 'mp4'}); fs.writeFileSync(existingPath, 'data'); t.context.paths = [existingPath]; recordingHistory.set('recordings', [{filePath: existingPath}, {filePath: missingPath}]); t.deepEqual(getPastRecordings(), [{filePath: existingPath} as PastRecording]); t.deepEqual(recordingHistory.get('recordings'), [{filePath: existingPath} as PastRecording]); }); test('`hasActiveRecording()` with no recording', async t => { t.false(await hasActiveRecording()); }); test('`hasActiveRecording()` with playable recording', async t => { const fakeService = { title: 'Fake Service', cleanUp: sinon.fake() }; const fakePlugin = { name: 'kap-fake-plugin', recordServices: [fakeService] }; plugins.recordingPlugins = [fakePlugin]; recordingHistory.set('activeRecording', { filePath: incomplete, name: 'Incomplete', date: new Date().toISOString(), apertureOptions: {}, plugins: { 'kap-fake-plugin': { 'Fake Service': { some: 'state' } } } }); const checkPromise = hasActiveRecording(); const dialogState = await dialog.waitForDialog(); t.true(dialogState.detail.includes('playable')); // Don't delete it until user is done interacting with the dialog t.true(recordingHistory.has('activeRecording')); await dialog.fakeAction(dialogState.buttons.findIndex((b: any) => b.label?.toLowerCase().includes('editor'))); const video = windowManager.editor.open.lastCall?.args?.[0] as Video; t.deepEqual(video?.filePath, incomplete); t.deepEqual(video?.title, 'Incomplete'); t.true(await checkPromise); t.false(recordingHistory.has('activeRecording')); t.true(fakeService.cleanUp.calledOnceWith({some: 'state'})); t.deepEqual( recordingHistory.get('recordings'), [ { filePath: incomplete, name: 'Incomplete', date: new Date().toISOString() } ] ); }); test('`hasActiveRecording()` with known corrupt recording', async t => { recordingHistory.set('activeRecording', { filePath: corrupt, name: 'Corrupt', date: new Date().toISOString(), apertureOptions: {}, plugins: {} }); const checkPromise = hasActiveRecording(); const dialogState = await dialog.waitForDialog(); t.true(dialogState.detail.includes('corrupt')); t.true(dialogState.detail.includes('moov atom not found')); t.truthy(dialogState.message); // Don't delete it until user is done interacting with the dialog t.true(recordingHistory.has('activeRecording')); await dialog.fakeAction(dialogState.defaultId); const newDialogState = await dialog.getCurrentState(); t.true(newDialogState.message.includes('unable')); await dialog.fakeAction(newDialogState.defaultId); t.true(shell.showItemInFolder.calledWithExactly(corrupt)); t.true(await checkPromise); t.false(recordingHistory.has('activeRecording')); t.is(recordingHistory.get('recordings').length, 0); }); test('`hasActiveRecording()` with unknown corrupt recording', async t => { const filePath = tempy.file(); fs.writeFileSync(filePath, 'data'); t.context.paths = [filePath]; recordingHistory.set('activeRecording', { filePath, name: 'Bad', date: new Date().toISOString(), apertureOptions: {}, plugins: {} }); const checkPromise = hasActiveRecording(); const dialogState = await dialog.waitForDialog(); t.true(dialogState.detail.includes('corrupt')); t.false(dialogState.detail.includes('moov atom not found')); t.falsy(dialogState.message); // Don't delete it until user is done interacting with the dialog t.true(recordingHistory.has('activeRecording')); await dialog.fakeAction(dialogState.defaultId); t.true(shell.showItemInFolder.calledWithExactly(filePath)); t.true(await checkPromise); t.false(recordingHistory.has('activeRecording')); t.is(recordingHistory.get('recordings').length, 0); const sentryError = Sentry.captureException.lastCall.args[0]; t.true(sentryError.message.startsWith('Corrupt recording:')); }); test('`setCurrentRecording()`', t => { setCurrentRecording({ filePath: 'some/path', apertureOptions: {some: 'options'} as any, plugins: {some: 'plugins'} as any }); t.deepEqual(recordingHistory.get('activeRecording'), { filePath: 'some/path', name: 'Kapture 2020-07-21 at 11.27.26', date: t.context.now.toISOString(), apertureOptions: {some: 'options'} as any, plugins: {some: 'plugins'} as any }); }); test('`updatePluginState()`', t => { recordingHistory.set('activeRecording', { name: 'Some name', plugins: { plugin1: { service1: {some: 'state'} }, plugin2: { service2: {} } } }); updatePluginState({ plugin1: { service1: {some: 'state'} }, plugin2: { service2: {some: 'other state'} } }); t.deepEqual(recordingHistory.get('activeRecording.plugins'), { plugin1: { service1: {some: 'state'} }, plugin2: { service2: {some: 'other state'} } }); }); test('`stopCurrentRecording()`', t => { const filePath = tempy.file({extension: 'mp4'}); fs.writeFileSync(filePath, 'data'); t.context.paths = [filePath]; recordingHistory.set('activeRecording', { filePath, name: 'some name' }); stopCurrentRecording(); t.false(recordingHistory.has('activeRecording')); t.deepEqual(recordingHistory.get('recordings'), [{filePath, name: 'some name', date: t.context.now.toISOString()}]); recordingHistory.set('activeRecording', { filePath, name: 'some name' }); stopCurrentRecording('new name'); t.false(recordingHistory.has('activeRecording')); t.deepEqual(recordingHistory.get('recordings'), [ {filePath, name: 'new name', date: t.context.now.toISOString()}, {filePath, name: 'some name', date: t.context.now.toISOString()} ]); }); test('`cleanPastRecordings()`', t => { const filePath = tempy.file({extension: 'mp4'}); fs.writeFileSync(filePath, 'data'); t.context.paths = [filePath]; recordingHistory.set('recordings', [ {filePath}, // Should ignore file that doesn't exist {filePath: tempy.file({extension: 'mp4'})} ]); cleanPastRecordings(); t.false(fs.existsSync(filePath)); }); test('`addRecording()`', t => { const filePath = tempy.file({extension: 'mp4'}); addRecording({filePath} as PastRecording); t.is(recordingHistory.get('recordings').length, 0); fs.writeFileSync(filePath, 'data'); t.context.paths = [filePath]; addRecording({filePath} as PastRecording); t.deepEqual(recordingHistory.get('recordings'), [{filePath} as PastRecording]); }); ================================================ FILE: test/tsconfig.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "noUnusedLocals": false, "baseUrl": "." }, "include": [ "**/*.ts" ] } ================================================ FILE: tsconfig.eslint.json ================================================ { "extends": "./tsconfig.json", "include": [ "main/**/*", "main/**/*.js", "test/**/*.ts", "test/**/*.js" ] } ================================================ FILE: tsconfig.json ================================================ { "extends": "@sindresorhus/tsconfig", "compilerOptions": { "outDir": "dist-js", "target": "es2019", "module": "commonjs", "esModuleInterop": true, "allowJs": true, "sourceMap": true, "inlineSources": true, "lib": [ "esnext" ] }, "include": [ "node_modules/type-fest/index.d.ts", "main/**/*" ], "exclude": [ "node_modules" ] }