Skip to content

Commit

Permalink
Recover recordings after unexpected exit/crash (wulkano#899)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <[email protected]>
  • Loading branch information
karaggeorge and sindresorhus authored Jul 29, 2020
1 parent 63a239a commit e11b3df
Show file tree
Hide file tree
Showing 21 changed files with 802 additions and 25 deletions.
4 changes: 3 additions & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ The record service is a plain object defining some metadata and 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.

Expand Down Expand Up @@ -286,7 +287,8 @@ You can use this to check if you have enough permissions for the service to work

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

- `.state`: A plain empty 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`: 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).
Expand Down
31 changes: 28 additions & 3 deletions main/common/aperture.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const plugins = require('./plugins');
const {getAudioDevices} = require('../utils/devices');
const {showError} = require('../utils/errors');
const {RecordServiceContext} = require('../service-context');
const {setCurrentRecording, updatePluginState, stopCurrentRecording} = require('../recording-history');

const aperture = createAperture();
const {videoCodecs} = createAperture;
Expand All @@ -35,6 +36,20 @@ const setRecordingName = name => {
recordingName = name;
};

const serializeEditPluginState = () => {
const result = {};

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 => Promise.all(recordingPlugins.map(async ({plugin, service}) => {
if (service[method] && typeof service[method] === 'function') {
try {
Expand Down Expand Up @@ -126,14 +141,21 @@ const startRecording = async options => {
);

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

await callPlugins('willStartRecording');

try {
await aperture.startRecording(apertureOptions);
const filePath = await aperture.startRecording(apertureOptions);

setCurrentRecording({
filePath,
name: recordingName,
apertureOptions,
editPlugins: serializeEditPluginState()
});
} catch (error) {
track('recording/stopped/error');
showError(error, {title: 'Recording error'});
Expand Down Expand Up @@ -167,6 +189,7 @@ const startRecording = async options => {
});

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

const stopRecording = async () => {
Expand Down Expand Up @@ -200,8 +223,10 @@ const stopRecording = async () => {
// if (recordHevc) {
// openEditorWindow(await convertToH264(filePath), {recordedFps, isNewRecording: true, originalFilePath: filePath});
// } else {
openEditorWindow(filePath, {recordedFps, isNewRecording: true, recordingName});
await openEditorWindow(filePath, {recordedFps, isNewRecording: true, recordingName});
// }

stopCurrentRecording(recordingName);
}
};

Expand Down
9 changes: 7 additions & 2 deletions main/common/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,19 @@ class Plugins {
this.updateExportOptions = updateExportOptions;
}

async enableService(service) {
async enableService(service, plugin) {
const wasEnabled = recordPluginServiceState.get(service.title) || false;

if (wasEnabled) {
recordPluginServiceState.set(service.title, false);
return this.refreshRecordPluginServices();
}

if (!plugin.config.validServices.includes(service.title)) {
openPrefsWindow({target: {name: plugin.name, action: 'configure'}});
return;
}

if (service.willEnable) {
try {
const canEnable = await service.willEnable();
Expand Down Expand Up @@ -69,7 +74,7 @@ class Plugins {
plugin => plugin.recordServices.map(service => ({
...service,
isEnabled: recordPluginServiceState.get(service.title) || false,
toggleEnabled: () => this.enableService(service)
toggleEnabled: () => this.enableService(service, plugin)
}))
)
);
Expand Down
8 changes: 3 additions & 5 deletions main/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ const EventEmitter = require('events');
const pify = require('pify');
const {ipcMain: ipc} = require('electron-better-ipc');
const {is} = require('electron-util');
const moment = require('moment');

const getFps = require('./utils/fps');
const loadRoute = require('./utils/routes');
const {generateTimestampedName} = require('./utils/timestamped-name');

const editors = new Map();
let allOptions;
Expand All @@ -22,7 +22,7 @@ const MIN_WINDOW_HEIGHT = MIN_VIDEO_HEIGHT + OPTIONS_BAR_HEIGHT;
const editorEmitter = new EventEmitter();
const editorsWithNotSavedDialogs = new Map();

const getEditorName = (filePath, isNewRecording) => isNewRecording ? `New Recording ${moment().format('YYYY-MM-DD')} at ${moment().format('H.mm.ss')}` : path.basename(filePath);
const getEditorName = (filePath, isNewRecording) => isNewRecording ? generateTimestampedName() : path.basename(filePath);

const openEditorWindow = async (
filePath,
Expand Down Expand Up @@ -121,10 +121,8 @@ const getEditors = () => editors.values();
const getEditor = path => editors.get(path);

ipc.answerRenderer('save-original', async ({inputPath}) => {
const now = moment();

const {filePath} = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow(), {
defaultPath: `Kapture ${now.format('YYYY-MM-DD')} at ${now.format('H.mm.ss')}.mp4`
defaultPath: generateTimestampedName('Kapture', '.mp4')
});

if (filePath) {
Expand Down
6 changes: 2 additions & 4 deletions main/export-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const ffmpeg = require('@ffmpeg-installer/ffmpeg');
const util = require('electron-util');
const execa = require('execa');
const makeDir = require('make-dir');
const moment = require('moment');

const settings = require('./common/settings');
const {track} = require('./common/analytics');
Expand All @@ -20,6 +19,7 @@ const {openEditorWindow} = require('./editor');
const {toggleExportMenuItem} = require('./menus');
const Export = require('./export');
const {ensureDockIsShowingSync} = require('./utils/dock');
const {generateTimestampedName} = require('./utils/timestamped-name');

const ffmpegPath = util.fixPathForAsarUnpack(ffmpeg.path);
let lastSavedDirectory;
Expand Down Expand Up @@ -58,10 +58,8 @@ const getDragIcon = async inputPath => {
};

const saveSnapshot = async ({inputPath, time}) => {
const now = moment();

const {filePath: outputPath} = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow(), {
defaultPath: `Snapshot ${now.format('YYYY-MM-DD')} at ${now.format('H.mm.ss')}.jpg`
defaultPath: generateTimestampedName('Snapshot', '.jpg')
});

if (outputPath) {
Expand Down
5 changes: 2 additions & 3 deletions main/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

const path = require('path');
const PCancelable = require('p-cancelable');
const moment = require('moment');

const {track} = require('./common/analytics');
const {convertTo} = require('./convert');
const {ShareServiceContext} = require('./service-context');
const {getFormatExtension} = require('./common/constants');
const PluginConfig = require('./utils/plugin-config');
const {generateTimestampedName} = require('./utils/timestamped-name');

class Export {
constructor(options) {
Expand Down Expand Up @@ -41,8 +41,7 @@ class Export {
this.isSaveFileService = options.sharePlugin.pluginName === '_saveToDisk';
this.disableOutputActions = false;

const now = moment();
const fileName = options.recordingName || (options.isNewRecording ? `Kapture ${now.format('YYYY-MM-DD')} at ${now.format('H.mm.ss')}` : path.parse(this.inputPath).name);
const fileName = options.recordingName || (options.isNewRecording ? generateTimestampedName('Kapture') : path.parse(this.inputPath).name);
this.defaultFileName = `${fileName}.${getFormatExtension(this.format)}`;

this.context = new ShareServiceContext({
Expand Down
15 changes: 11 additions & 4 deletions main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {initializeExportOptions} = require('./export-options');
const settings = require('./common/settings');
const {hasMicrophoneAccess, ensureScreenCapturePermissions} = require('./common/system-permissions');
const {handleDeepLink} = require('./utils/deep-linking');
const {hasActiveRecording, cleanPastRecordings} = require('./recording-history');

require('./utils/sentry');
require('./utils/errors').setupErrorHandling();
Expand Down Expand Up @@ -88,21 +89,23 @@ const checkForUpdates = () => {
initializeExportOptions();
setApplicationMenu();

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())
) {
openCropperWindow();
}

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

checkForUpdates();
})();

Expand All @@ -125,3 +128,7 @@ app.on('will-finish-launching', () => {
handleDeepLink(url);
});
});

app.on('quit', () => {
cleanPastRecordings();
});
Loading

0 comments on commit e11b3df

Please sign in to comment.