add option to run script directly in desktop app
This commit is contained in:
@@ -14,9 +14,11 @@
|
||||
|
||||
## Get started
|
||||
|
||||
- Online version: [https://privacy.sexy](https://privacy.sexy)
|
||||
- or download latest desktop version for [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.9.2/privacy.sexy-Setup-0.9.2.exe), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.9.2/privacy.sexy-0.9.2.AppImage), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.9.2/privacy.sexy-0.9.2.dmg)
|
||||
- 💡 Come back regularly to apply latest version for stronger privacy and security.
|
||||
- Online version at [https://privacy.sexy](https://privacy.sexy)
|
||||
- 💡 No need to run any compiled software on your computer.
|
||||
- Alternatively download offline version for [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.9.2/privacy.sexy-Setup-0.9.2.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.9.2/privacy.sexy-0.9.2.dmg) or [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.9.2/privacy.sexy-0.9.2.AppImage).
|
||||
- 💡 Single click to execute your script.
|
||||
- ❗ Come back regularly to apply latest version for stronger privacy and security.
|
||||
|
||||
[](https://privacy.sexy)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
export class Clipboard {
|
||||
public static copyText(text: string): void {
|
||||
const el = document.createElement('textarea');
|
||||
|
||||
69
src/infrastructure/CodeRunner.ts
Normal file
69
src/infrastructure/CodeRunner.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Environment } from '@/application/Environment/Environment';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import child_process from 'child_process';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
|
||||
export async function runCodeAsync(
|
||||
code: string, folderName: string, fileExtension: string,
|
||||
node = getNodeJs(), environment = Environment.CurrentEnvironment): Promise<void> {
|
||||
const dir = node.path.join(node.os.tmpdir(), folderName);
|
||||
await node.fs.promises.mkdir(dir, {recursive: true});
|
||||
const filePath = node.path.join(dir, `run.${fileExtension}`);
|
||||
await node.fs.promises.writeFile(filePath, code);
|
||||
await node.fs.promises.chmod(filePath, '755');
|
||||
const command = getExecuteCommand(filePath, environment);
|
||||
node.child_process.exec(command);
|
||||
}
|
||||
|
||||
function getExecuteCommand(scriptPath: string, environment: Environment): string {
|
||||
switch (environment.os) {
|
||||
case OperatingSystem.macOS:
|
||||
return `open -a Terminal.app ${scriptPath}`;
|
||||
// Another option with graphical sudo would be
|
||||
// `osascript -e "do shell script \\"${scriptPath}\\" with administrator privileges"`
|
||||
// However it runs in background
|
||||
case OperatingSystem.Windows:
|
||||
return scriptPath;
|
||||
default:
|
||||
throw Error('undefined os');
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeJs(): INodeJs {
|
||||
return { os, path, fs, child_process };
|
||||
}
|
||||
|
||||
export interface INodeJs {
|
||||
os: INodeOs;
|
||||
path: INodePath;
|
||||
fs: INodeFs;
|
||||
child_process: INodeChildProcess;
|
||||
}
|
||||
|
||||
export interface INodeOs {
|
||||
tmpdir(): string;
|
||||
}
|
||||
|
||||
export interface INodePath {
|
||||
join(...paths: string[]): string;
|
||||
}
|
||||
|
||||
export interface INodeChildProcess {
|
||||
exec(command: string): void;
|
||||
}
|
||||
|
||||
export interface INodeFs {
|
||||
readonly promises: INodeFsPromises;
|
||||
}
|
||||
|
||||
interface INodeFsPromisesMakeDirectoryOptions {
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
interface INodeFsPromises { // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/v13/fs.d.ts
|
||||
chmod(path: string, mode: string | number): Promise<void>;
|
||||
mkdir(path: string, options: INodeFsPromisesMakeDirectoryOptions): Promise<string>;
|
||||
writeFile(path: string, data: string): Promise<void>;
|
||||
}
|
||||
@@ -7,8 +7,7 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faFolderOpen, faFolder, faSmile } from '@fortawesome/free-regular-svg-icons';
|
||||
/** SOLID ICONS (PREFIX: fas (default)) */
|
||||
import { faTimes, faFileDownload, faCopy, faSearch, faInfoCircle, faUserSecret, faDesktop,
|
||||
faTag, faGlobe, faSave, faBatteryFull, faBatteryHalf } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
faTag, faGlobe, faSave, faBatteryFull, faBatteryHalf, faPlay } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export class IconBootstrapper implements IVueBootstrapper {
|
||||
public bootstrap(vue: VueConstructor): void {
|
||||
@@ -24,6 +23,7 @@ export class IconBootstrapper implements IVueBootstrapper {
|
||||
faTimes,
|
||||
faFileDownload, faSave,
|
||||
faCopy,
|
||||
faPlay,
|
||||
faSearch,
|
||||
faBatteryFull, faBatteryHalf,
|
||||
faInfoCircle);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="instructions">
|
||||
<!-- <p>
|
||||
<p>
|
||||
Since you're using online version of {{ this.appName }}, you will need to do additional steps after downloading the file to execute your script on macOS:
|
||||
</p> -->
|
||||
</p>
|
||||
<p>
|
||||
<ol>
|
||||
<li>
|
||||
@@ -73,9 +73,9 @@
|
||||
</li>
|
||||
</ol>
|
||||
</p>
|
||||
<!-- <p>
|
||||
<p>
|
||||
Or download the <a :href="this.macOsDownloadUrl">offline version</a> to run your scripts directly to skip these steps.
|
||||
</p> -->
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div class="container" v-if="hasCode">
|
||||
<IconButton
|
||||
v-if="this.canRun"
|
||||
text="Run"
|
||||
v-on:click="executeCodeAsync"
|
||||
icon-prefix="fas" icon-name="play">
|
||||
</IconButton>
|
||||
<IconButton
|
||||
:text="this.isDesktopVersion ? 'Save' : 'Download'"
|
||||
v-on:click="saveCodeAsync"
|
||||
@@ -38,6 +44,8 @@ import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { runCodeAsync } from '@/infrastructure/CodeRunner';
|
||||
import { IApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@@ -48,8 +56,9 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
export default class TheCodeButtons extends StatefulVue {
|
||||
public readonly macOsModalName = 'macos-instructions';
|
||||
|
||||
public readonly isDesktopVersion = Environment.CurrentEnvironment.isDesktop;
|
||||
public canRun = false;
|
||||
public hasCode = false;
|
||||
public isDesktopVersion = Environment.CurrentEnvironment.isDesktop;
|
||||
public isMacOsCollection = false;
|
||||
public fileName = '';
|
||||
|
||||
@@ -64,8 +73,13 @@ export default class TheCodeButtons extends StatefulVue {
|
||||
this.$modal.show(this.macOsModalName);
|
||||
}
|
||||
}
|
||||
public async executeCodeAsync() {
|
||||
const context = await this.getCurrentContextAsync();
|
||||
await executeCodeAsync(context);
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: ICategoryCollectionState): void {
|
||||
this.canRun = this.isDesktopVersion && newState.collection.os === Environment.CurrentEnvironment.os;
|
||||
this.isMacOsCollection = newState.collection.os === OperatingSystem.macOS;
|
||||
this.fileName = buildFileName(newState.collection.scripting);
|
||||
this.react(newState.code);
|
||||
@@ -108,6 +122,15 @@ function buildFileName(scripting: IScriptingDefinition) {
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async function executeCodeAsync(context: IApplicationContext) {
|
||||
await runCodeAsync(
|
||||
/*code*/ context.state.code.current,
|
||||
/*appName*/ context.app.info.name,
|
||||
/*fileExtension*/ context.state.collection.scripting.fileExtension,
|
||||
);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
249
tests/unit/infrastructure/CodeRunner.spec.ts
Normal file
249
tests/unit/infrastructure/CodeRunner.spec.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { EnvironmentStub } from './../stubs/EnvironmentStub';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { runCodeAsync } from '@/infrastructure/CodeRunner';
|
||||
|
||||
describe('CodeRunner', () => {
|
||||
describe('runCodeAsync', () => {
|
||||
it('creates temporary directory recursively', async () => {
|
||||
// arrange
|
||||
const expectedDir = 'expected-dir';
|
||||
const folderName = 'privacy.sexy';
|
||||
const context = new TestContext();
|
||||
context.mocks.os.setupTmpdir('tmp');
|
||||
context.mocks.path.setupJoin(expectedDir, 'tmp', folderName);
|
||||
|
||||
// act
|
||||
await context
|
||||
.withFolderName(folderName)
|
||||
.runCodeAsync();
|
||||
|
||||
// assert
|
||||
expect(context.mocks.fs.mkdirHistory.length).to.equal(1);
|
||||
expect(context.mocks.fs.mkdirHistory[0].isRecursive).to.equal(true);
|
||||
expect(context.mocks.fs.mkdirHistory[0].path).to.equal(expectedDir);
|
||||
});
|
||||
it('creates a file with expected code and path', async () => {
|
||||
// arrange
|
||||
const expectedCode = 'expected-code';
|
||||
const expectedFilePath = 'expected-file-path';
|
||||
|
||||
const extension = '.sh';
|
||||
const expectedName = `run.${extension}`;
|
||||
const folderName = 'privacy.sexy';
|
||||
const context = new TestContext();
|
||||
context.mocks.os.setupTmpdir('tmp');
|
||||
context.mocks.path.setupJoin('folder', 'tmp', folderName);
|
||||
context.mocks.path.setupJoin(expectedFilePath, 'folder', expectedName);
|
||||
|
||||
// act
|
||||
await context
|
||||
.withCode(expectedCode)
|
||||
.withFolderName(folderName)
|
||||
.withExtension(extension)
|
||||
.runCodeAsync();
|
||||
|
||||
// assert
|
||||
expect(context.mocks.fs.writeFileHistory.length).to.equal(1);
|
||||
expect(context.mocks.fs.writeFileHistory[0].data).to.equal(expectedCode);
|
||||
expect(context.mocks.fs.writeFileHistory[0].path).to.equal(expectedFilePath);
|
||||
});
|
||||
it('set file permissions as expected', async () => {
|
||||
// arrange
|
||||
const expectedMode = '755';
|
||||
const expectedFilePath = 'expected-file-path';
|
||||
|
||||
const extension = '.sh';
|
||||
const expectedName = `run.${extension}`;
|
||||
const folderName = 'privacy.sexy';
|
||||
const context = new TestContext();
|
||||
context.mocks.os.setupTmpdir('tmp');
|
||||
context.mocks.path.setupJoin('folder', 'tmp', folderName);
|
||||
context.mocks.path.setupJoin(expectedFilePath, 'folder', expectedName);
|
||||
|
||||
// act
|
||||
await context
|
||||
.withFolderName(folderName)
|
||||
.withExtension(extension)
|
||||
.runCodeAsync();
|
||||
|
||||
// assert
|
||||
expect(context.mocks.fs.chmodCallHistory.length).to.equal(1);
|
||||
expect(context.mocks.fs.chmodCallHistory[0].mode).to.equal(expectedMode);
|
||||
expect(context.mocks.fs.chmodCallHistory[0].path).to.equal(expectedFilePath);
|
||||
});
|
||||
describe('executes as expected', () => {
|
||||
// arrange
|
||||
const filePath = 'expected-file-path';
|
||||
const testData = [ {
|
||||
os: OperatingSystem.Windows,
|
||||
expected: filePath,
|
||||
}, {
|
||||
os: OperatingSystem.macOS,
|
||||
expected: `open -a Terminal.app ${filePath}`,
|
||||
}];
|
||||
for (const data of testData) {
|
||||
it(`returns ${data.expected} on ${OperatingSystem[data.os]}`, async () => {
|
||||
const context = new TestContext();
|
||||
context.mocks.os.setupTmpdir('non-important-temp-dir-name');
|
||||
context.mocks.path.setupJoinSequence('non-important-folder-name', filePath);
|
||||
context.withOs(data.os);
|
||||
|
||||
// act
|
||||
await context
|
||||
.withOs(data.os)
|
||||
.runCodeAsync();
|
||||
|
||||
// assert
|
||||
expect(context.mocks.child_process.executionHistory.length).to.equal(1);
|
||||
expect(context.mocks.child_process.executionHistory[0]).to.equal(data.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
it('runs in expected order', async () => {
|
||||
// arrange
|
||||
const expectedOrder = [ NodeJsCommand.mkdir, NodeJsCommand.writeFile, NodeJsCommand.chmod ];
|
||||
const context = new TestContext();
|
||||
context.mocks.os.setupTmpdir('non-important-temp-dir-name');
|
||||
context.mocks.path.setupJoinSequence('non-important-folder-name1', 'non-important-folder-name2');
|
||||
|
||||
// act
|
||||
await context.runCodeAsync();
|
||||
|
||||
// assert
|
||||
const actualOrder = context.mocks.commandHistory.filter((command) => expectedOrder.includes(command));
|
||||
expect(expectedOrder).to.deep.equal(actualOrder);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TestContext {
|
||||
public mocks = getNodeJsMocks();
|
||||
|
||||
private code: string = 'code';
|
||||
private folderName: string = 'folderName';
|
||||
private fileExtension: string = 'fileExtension';
|
||||
private env = mockEnvironment(OperatingSystem.Windows);
|
||||
|
||||
public async runCodeAsync(): Promise<void> {
|
||||
await runCodeAsync(this.code, this.folderName, this.fileExtension, this.mocks, this.env);
|
||||
}
|
||||
public withOs(os: OperatingSystem) {
|
||||
this.env = mockEnvironment(os);
|
||||
return this;
|
||||
}
|
||||
public withFolderName(folderName: string) {
|
||||
this.folderName = folderName;
|
||||
return this;
|
||||
}
|
||||
public withCode(code: string) {
|
||||
this.code = code;
|
||||
return this;
|
||||
}
|
||||
public withExtension(fileExtension: string) {
|
||||
this.fileExtension = fileExtension;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
function mockEnvironment(os: OperatingSystem) {
|
||||
return new EnvironmentStub().withOs(os);
|
||||
}
|
||||
|
||||
const enum NodeJsCommand { tmpdir, join, exec, mkdir, writeFile, chmod }
|
||||
|
||||
function getNodeJsMocks() {
|
||||
const commandHistory = new Array<NodeJsCommand>();
|
||||
return {
|
||||
os: mockOs(commandHistory),
|
||||
path: mockPath(commandHistory),
|
||||
fs: mockNodeFs(commandHistory),
|
||||
child_process: mockChildProcess(commandHistory),
|
||||
commandHistory,
|
||||
};
|
||||
}
|
||||
|
||||
function mockOs(commandHistory: NodeJsCommand[]) {
|
||||
let tmpDir: string;
|
||||
return {
|
||||
setupTmpdir: (value: string): void => {
|
||||
tmpDir = value;
|
||||
},
|
||||
tmpdir: (): string => {
|
||||
if (!tmpDir) {
|
||||
throw new Error('tmpdir not set up');
|
||||
}
|
||||
commandHistory.push(NodeJsCommand.tmpdir);
|
||||
return tmpDir;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockPath(commandHistory: NodeJsCommand[]) {
|
||||
const sequence = new Array<string>();
|
||||
const scenarios = new Map<string, string>();
|
||||
const getScenarioKey = (paths: string[]) => paths.join('|');
|
||||
return {
|
||||
setupJoin: (returnValue: string, ...paths: string[]): void => {
|
||||
scenarios.set(getScenarioKey(paths), returnValue);
|
||||
},
|
||||
setupJoinSequence: (...valuesToReturn: string[]): void => {
|
||||
sequence.push(...valuesToReturn);
|
||||
sequence.reverse();
|
||||
},
|
||||
join: (...paths: string[]): string => {
|
||||
commandHistory.push(NodeJsCommand.join);
|
||||
if (sequence.length > 0) {
|
||||
return sequence.pop();
|
||||
}
|
||||
const key = getScenarioKey(paths);
|
||||
if (!scenarios.has(key)) {
|
||||
return paths.join('/');
|
||||
}
|
||||
return scenarios.get(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockChildProcess(commandHistory: NodeJsCommand[]) {
|
||||
const executionHistory = new Array<string>();
|
||||
return {
|
||||
exec: (command: string): void => {
|
||||
commandHistory.push(NodeJsCommand.exec);
|
||||
executionHistory.push(command);
|
||||
},
|
||||
executionHistory,
|
||||
};
|
||||
}
|
||||
|
||||
function mockNodeFs(commandHistory: NodeJsCommand[]) {
|
||||
interface IMkdirCall { path: string; isRecursive: boolean; }
|
||||
interface IWriteFileCall { path: string; data: string; }
|
||||
interface IChmodCall { path: string; mode: string | number; }
|
||||
const mkdirHistory = new Array<IMkdirCall>();
|
||||
const writeFileHistory = new Array<IWriteFileCall>();
|
||||
const chmodCallHistory = new Array<IChmodCall>();
|
||||
return {
|
||||
promises: {
|
||||
mkdir: (path, options) => {
|
||||
commandHistory.push(NodeJsCommand.mkdir);
|
||||
mkdirHistory.push({ path, isRecursive: options && options.recursive });
|
||||
return Promise.resolve(path);
|
||||
},
|
||||
writeFile: (path, data) => {
|
||||
commandHistory.push(NodeJsCommand.writeFile);
|
||||
writeFileHistory.push({ path, data });
|
||||
return Promise.resolve();
|
||||
},
|
||||
chmod: (path, mode) => {
|
||||
commandHistory.push(NodeJsCommand.chmod);
|
||||
chmodCallHistory.push({ path, mode });
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
mkdirHistory,
|
||||
writeFileHistory,
|
||||
chmodCallHistory,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user