Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): opt-in anonymous usage data #4022

Merged
merged 10 commits into from
Jan 8, 2021
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@ionic/utils-terminal": "^2.3.0",
"commander": "^6.0.0",
"debug": "^4.2.0",
"env-paths": "^2.2.0",
"kleur": "^4.1.1",
"native-run": "^1.2.1",
"open": "^7.1.0",
Expand Down
109 changes: 74 additions & 35 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import c from './colors';
import { loadConfig } from './config';
import type { Config } from './definitions';
import { fatal, isFatal } from './errors';
import { receive } from './ipc';
import { logger, output } from './log';
import { telemetryAction } from './telemetry';
import { wrapAction } from './util/cli';
import { emoji as _e } from './util/emoji';

process.on('unhandledRejection', error => {
console.error(c.failure('[fatal]'), error);
});

process.on('message', receive);

export async function run(): Promise<void> {
try {
const config = await loadConfig();
Expand Down Expand Up @@ -54,10 +58,12 @@ export function runProgram(config: Config): void {
'Optional: Directory of your projects built web assets',
)
.action(
wrapAction(async (appName, appId, { webDir }) => {
const { initCommand } = await import('./tasks/init');
await initCommand(config, appName, appId, webDir);
}),
wrapAction(
telemetryAction(config, async (appName, appId, { webDir }) => {
const { initCommand } = await import('./tasks/init');
await initCommand(config, appName, appId, webDir);
}),
),
);

program
Expand All @@ -78,10 +84,12 @@ export function runProgram(config: Config): void {
"Optional: if provided, Podfile.lock won't be deleted and pod install will use --deployment option",
)
.action(
wrapAction(async (platform, { deployment }) => {
const { syncCommand } = await import('./tasks/sync');
await syncCommand(config, platform, deployment);
}),
wrapAction(
telemetryAction(config, async (platform, { deployment }) => {
const { syncCommand } = await import('./tasks/sync');
await syncCommand(config, platform, deployment);
}),
),
);

program
Expand All @@ -96,20 +104,24 @@ export function runProgram(config: Config): void {
"Optional: if provided, Podfile.lock won't be deleted and pod install will use --deployment option",
)
.action(
wrapAction(async (platform, { deployment }) => {
const { updateCommand } = await import('./tasks/update');
await updateCommand(config, platform, deployment);
}),
wrapAction(
telemetryAction(config, async (platform, { deployment }) => {
const { updateCommand } = await import('./tasks/update');
await updateCommand(config, platform, deployment);
}),
),
);

program
.command('copy [platform]')
.description('copies the web app build into the native app')
.action(
wrapAction(async platform => {
const { copyCommand } = await import('./tasks/copy');
await copyCommand(config, platform);
}),
wrapAction(
telemetryAction(config, async platform => {
const { copyCommand } = await import('./tasks/copy');
await copyCommand(config, platform);
}),
),
);

program
Expand All @@ -123,52 +135,79 @@ export function runProgram(config: Config): void {
.option('--target <id>', 'use a specific target')
.option('--no-sync', `do not run ${c.input('sync')}`)
.action(
wrapAction(async (platform, { list, target, sync }) => {
const { runCommand } = await import('./tasks/run');
await runCommand(config, platform, { list, target, sync });
}),
wrapAction(
telemetryAction(config, async (platform, { list, target, sync }) => {
const { runCommand } = await import('./tasks/run');
await runCommand(config, platform, { list, target, sync });
}),
),
);

program
.command('open [platform]')
.description('opens the native project workspace (Xcode for iOS)')
.action(
wrapAction(async platform => {
const { openCommand } = await import('./tasks/open');
await openCommand(config, platform);
}),
wrapAction(
telemetryAction(config, async platform => {
const { openCommand } = await import('./tasks/open');
await openCommand(config, platform);
}),
),
);

program
.command('add [platform]')
.description('add a native platform project')
.action(
wrapAction(async platform => {
const { addCommand } = await import('./tasks/add');
await addCommand(config, platform);
}),
wrapAction(
telemetryAction(config, async platform => {
const { addCommand } = await import('./tasks/add');
await addCommand(config, platform);
}),
),
);

program
.command('ls [platform]')
.description('list installed Cordova and Capacitor plugins')
.action(
wrapAction(async platform => {
const { listCommand } = await import('./tasks/list');
await listCommand(config, platform);
}),
wrapAction(
telemetryAction(config, async platform => {
const { listCommand } = await import('./tasks/list');
await listCommand(config, platform);
}),
),
);

program
.command('doctor [platform]')
.description('checks the current setup for common errors')
.action(
wrapAction(async platform => {
const { doctorCommand } = await import('./tasks/doctor');
await doctorCommand(config, platform);
wrapAction(
telemetryAction(config, async platform => {
const { doctorCommand } = await import('./tasks/doctor');
await doctorCommand(config, platform);
}),
),
);

program
.command('telemetry [on|off]', { hidden: true })
.description('enable or disable telemetry')
.action(
wrapAction(async onOrOff => {
const { telemetryCommand } = await import('./tasks/telemetry');
await telemetryCommand(onOrOff);
}),
);

program
.command('📡', { hidden: true })
.description('IPC receiver command')
.action(() => {
// no-op: IPC messages are received via `process.on('message')`
});

program
.command('plugin:generate', { hidden: true })
.description('start a new Capacitor plugin')
Expand Down
89 changes: 89 additions & 0 deletions cli/src/ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { open, mkdirp } from '@ionic/utils-fs';
import { fork } from '@ionic/utils-subprocess';
import Debug from 'debug';
import { request } from 'https';
import { resolve } from 'path';

import type { Metric } from './telemetry';
import { ENV_PATHS } from './util/cli';

const debug = Debug('capacitor:ipc');

export interface TelemetryIPCMessage {
type: 'telemetry';
data: Metric<string, unknown>;
}

export type IPCMessage = TelemetryIPCMessage;

/**
* Send an IPC message to a forked process.
*/
export async function send(msg: IPCMessage): Promise<void> {
const dir = ENV_PATHS.log;
await mkdirp(dir);
const logPath = resolve(dir, 'ipc.log');

debug(
'Sending %O IPC message to forked process (logs: %O)',
msg.type,
logPath,
);

const fd = await open(logPath, 'a');
const p = fork(process.argv[1], ['📡'], { stdio: ['ignore', fd, fd, 'ipc'] });

p.send(msg);
p.disconnect();
p.unref();
}

/**
* Receive and handle an IPC message.
*
* Assume minimal context and keep external dependencies to a minimum.
*/
export async function receive(msg: IPCMessage): Promise<void> {
debug('Received %O IPC message', msg.type);

if (msg.type === 'telemetry') {
const now = new Date().toISOString();
const { data } = msg;

// This request is only made if telemetry is on.
const req = request(
{
hostname: 'api.ionicjs.com',
port: 443,
path: '/events/metrics',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
response => {
debug(
'Sent %O metric to events service (status: %O)',
data.name,
response.statusCode,
);

if (response.statusCode !== 204) {
response.on('data', chunk => {
debug(
'Bad response from events service. Request body: %O',
chunk.toString(),
);
});
}
},
);

const body = {
metrics: [data],
sent_at: now,
};

req.end(JSON.stringify(body));
}
}
52 changes: 52 additions & 0 deletions cli/src/sysconfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { readJSON, writeJSON, mkdirp } from '@ionic/utils-fs';
import Debug from 'debug';
import { dirname, resolve } from 'path';

import { ENV_PATHS } from './util/cli';
import { uuidv4 } from './util/uuid';

const debug = Debug('capacitor:sysconfig');

const SYSCONFIG_FILE = 'sysconfig.json';
const SYSCONFIG_PATH = resolve(ENV_PATHS.config, SYSCONFIG_FILE);

export interface SystemConfig {
/**
* A UUID that anonymously identifies this computer.
*/
readonly machine: string;

/**
* Whether telemetry is enabled or not.
*
* If undefined, a choice has not yet been made.
*/
readonly telemetry?: boolean;
}

export async function readConfig(): Promise<SystemConfig> {
debug('Reading from %O', SYSCONFIG_PATH);

try {
return await readJSON(SYSCONFIG_PATH);
} catch (e) {
if (e.code !== 'ENOENT') {
throw e;
}

const sysconfig: SystemConfig = {
machine: uuidv4(),
};

await writeConfig(sysconfig);

return sysconfig;
}
}

export async function writeConfig(sysconfig: SystemConfig): Promise<void> {
debug('Writing to %O', SYSCONFIG_PATH);

await mkdirp(dirname(SYSCONFIG_PATH));
await writeJSON(SYSCONFIG_PATH, sysconfig, { spaces: '\t' });
}
10 changes: 5 additions & 5 deletions cli/src/tasks/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ export async function initCommand(

printNextSteps(config);
} catch (e) {
output.write(
'Usage: npx cap init appName appId\n' +
'Example: npx cap init "My App" "com.example.myapp"\n\n',
);

if (!isFatal(e)) {
output.write(
'Usage: npx cap init appName appId\n' +
'Example: npx cap init "My App" "com.example.myapp"\n\n',
);

fatal(e.stack ?? e);
}

Expand Down
Loading