Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"activationEvents": [
"onCommand:extension.compressFile",
"onCommand:extension.compressFolder",
"onCommand:extension.getCompressionCount"
"onCommand:extension.getCompressionCount",
"onCommand:extension.setApiKey"
],
"main": "./out/src/extension.js",
"contributes": {
Expand All @@ -28,7 +29,8 @@
"properties": {
"tinypng.apiKey": {
"type": "string",
"description": "Your TinyPNG API Key"
"description": "DEPRECATED: Use Command Palette > 'TinyPNG: Set API Key' instead. Your TinyPNG API Key",
"deprecationMessage": "Please use the Command Palette command 'TinyPNG: Set API Key' to store your key securely."
},
"tinypng.forceOverwrite": {
"type": "boolean",
Expand Down Expand Up @@ -70,13 +72,21 @@
"command": "extension.compressGitStage",
"title": "Compress images in git stage",
"category": "TinyPNG"
},
{
"command": "extension.setApiKey",
"title": "TinyPNG: Set API Key",
"category": "TinyPNG"
}
],
"menus": {
"commandPalette": [
{
"command": "extension.getCompressionCount"
},
{
"command": "extension.setApiKey"
},
{
"command": "extension.compressFile",
"when": "False"
Expand Down
34 changes: 34 additions & 0 deletions src/commands/setApiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import vscode = require('vscode');
import { ConfigService } from '../services/configService';
import { CompressionService } from '../services/compressionService';

/**
* Command to securely set the TinyPNG API key
*/
export async function setApiKeyCommand(context: vscode.ExtensionContext): Promise<void> {
const apiKey = await vscode.window.showInputBox({
prompt: 'Enter your TinyPNG API Key',
password: true,
ignoreFocusOut: true,
placeHolder: 'Your API key from https://tinypng.com/developers'
});

if (apiKey) {
await ConfigService.setApiKey(context, apiKey);

// Re-initialize the compression service with the new key
await CompressionService.initialize(context);

// Validate the new API key
CompressionService.validate(
() => {
vscode.window.showInformationMessage('TinyPNG API key saved and validated successfully!');
},
(error: Error) => {
vscode.window.showWarningMessage(
`TinyPNG API key saved, but validation failed: ${error.message}. Please check if the key is correct.`
);
}
);
}
}
35 changes: 26 additions & 9 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,30 @@ import { compressFileCommand } from './commands/compressFile';
import { compressFolderCommand } from './commands/compressFolder';
import { compressGitStageCommand } from './commands/compressGitStage';
import { getCompressionCountCommand } from './commands/getCompressionCount';
import { setApiKeyCommand } from './commands/setApiKey';
import { handleValidationError } from './utils/errorHandler';

/**
* Activate the extension
*/
export function activate(context: vscode.ExtensionContext): void {
// Initialize the compression service with API key
CompressionService.initialize();

// Validate the API key
CompressionService.validate(
() => console.log('Validation successful!'),
handleValidationError
);
export async function activate(context: vscode.ExtensionContext): Promise<void> {
// Initialize the compression service with API key from SecretStorage
try {
await CompressionService.initialize(context);

// Validate the API key (fire and forget - don't block activation)
CompressionService.validate(
() => console.log('TinyPNG: API key validated successfully'),
handleValidationError
);
} catch (error) {
console.error('TinyPNG: Failed to initialize extension', error);
vscode.window.showErrorMessage(
`TinyPNG: Extension initialization failed. ${error instanceof Error ? error.message : 'Unknown error'}`
);
// Continue to register commands even if initialization fails
// This allows users to still set their API key
}

// Register command: Compress single file
const disposableCompressFile = vscode.commands.registerCommand(
Expand Down Expand Up @@ -46,6 +56,13 @@ export function activate(context: vscode.ExtensionContext): void {
compressGitStageCommand
);
context.subscriptions.push(disposableCompressGitStage);

// Register command: Set API key securely
const disposableSetApiKey = vscode.commands.registerCommand(
'extension.setApiKey',
() => setApiKeyCommand(context)
);
context.subscriptions.push(disposableSetApiKey);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/services/compressionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
*/
export class CompressionService {
/**
* Initialize the TinyPNG API with the API key
* Initialize the TinyPNG API with the API key from SecretStorage
*/
public static initialize(): void {
const apiKey = ConfigService.getApiKey();
public static async initialize(context: vscode.ExtensionContext): Promise<void> {
const apiKey = await ConfigService.getApiKey(context);
if (apiKey) {
tinify.key = apiKey;
}
Expand All @@ -26,7 +26,7 @@
*/
public static validate(
onSuccess: () => void = () => {},
onFailure?: (error: Error) => void

Check warning on line 29 in src/services/compressionService.ts

View workflow job for this annotation

GitHub Actions / build-and-test (25.x)

'error' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 29 in src/services/compressionService.ts

View workflow job for this annotation

GitHub Actions / build-and-test (23.x)

'error' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 29 in src/services/compressionService.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22.x)

'error' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 29 in src/services/compressionService.ts

View workflow job for this annotation

GitHub Actions / build-and-test (24.x)

'error' is defined but never used. Allowed unused args must match /^_/u
): void {
tinify.validate((err: Error | null) => {
if (err) {
Expand Down Expand Up @@ -82,7 +82,7 @@
*/
public static compressImageWithCallback(
file: Uri,
callback: (result: CompressionResult) => void

Check warning on line 85 in src/services/compressionService.ts

View workflow job for this annotation

GitHub Actions / build-and-test (25.x)

'result' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 85 in src/services/compressionService.ts

View workflow job for this annotation

GitHub Actions / build-and-test (23.x)

'result' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 85 in src/services/compressionService.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22.x)

'result' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 85 in src/services/compressionService.ts

View workflow job for this annotation

GitHub Actions / build-and-test (24.x)

'result' is defined but never used. Allowed unused args must match /^_/u
): void {
const shouldOverwrite = ConfigService.shouldOverwrite();
const postfix = ConfigService.getCompressedFilePostfix();
Expand Down
65 changes: 62 additions & 3 deletions src/services/configService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { TinyPngConfig } from '../types';
*/
export class ConfigService {
private static readonly CONFIG_SECTION = 'tinypng';
private static readonly SECRET_KEY = 'tinypng.apiKey';
private static readonly MIGRATION_STATE_KEY = 'tinypng.migrationCompleted';

/**
* Get the current TinyPNG configuration
Expand All @@ -21,12 +23,69 @@ export class ConfigService {
}

/**
* Get the API key from configuration
* Get the API key from SecretStorage (preferred) or fallback to settings
*/
public static getApiKey(): string | undefined {
return vscode.workspace
public static async getApiKey(context: vscode.ExtensionContext): Promise<string | undefined> {
// Try SecretStorage first
const secretKey = await context.secrets.get(this.SECRET_KEY);
if (secretKey) {
return secretKey;
}

// Fallback to settings for backward compatibility
const settingsKey = vscode.workspace
.getConfiguration(this.CONFIG_SECTION)
.get<string>('apiKey');

// Migrate to SecretStorage if found in settings
if (settingsKey) {
await this.setApiKey(context, settingsKey);

// Check if we've already shown the migration warning
const migrationCompleted = context.globalState.get<boolean>(this.MIGRATION_STATE_KEY);
if (!migrationCompleted) {
await context.globalState.update(this.MIGRATION_STATE_KEY, true);

// Prompt user with actionable options
const action = await vscode.window.showWarningMessage(
'TinyPNG: API key found in settings.json has been migrated to secure storage. Remove it from settings for security?',
'Remove from Settings',
'I\'ll Do It Manually'
);

if (action === 'Remove from Settings') {
try {
await vscode.workspace.getConfiguration(this.CONFIG_SECTION)
.update('apiKey', undefined, vscode.ConfigurationTarget.Global);
await vscode.workspace.getConfiguration(this.CONFIG_SECTION)
.update('apiKey', undefined, vscode.ConfigurationTarget.Workspace);
vscode.window.showInformationMessage('TinyPNG: API key removed from settings and stored securely.');
} catch (err) {
vscode.window.showWarningMessage(
'TinyPNG: Could not automatically remove API key from settings. Please remove tinypng.apiKey manually from settings.json.'
);
}
}
}

return settingsKey;
}

return undefined;
}

/**
* Store API key securely in SecretStorage
*/
public static async setApiKey(context: vscode.ExtensionContext, apiKey: string): Promise<void> {
await context.secrets.store(this.SECRET_KEY, apiKey);
}

/**
* Delete API key from SecretStorage
*/
public static async deleteApiKey(context: vscode.ExtensionContext): Promise<void> {
await context.secrets.delete(this.SECRET_KEY);
}

/**
Expand Down
11 changes: 8 additions & 3 deletions src/utils/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ export function handleCompressionError(error: Error): void {
*/
export function handleValidationError(error: Error): void {
console.error(error.message);
vscode.window.showInformationMessage(
'TinyPNG: API validation failed. Be sure that you filled out tinypng.apiKey setting already.'
);
vscode.window.showErrorMessage(
'TinyPNG: API validation failed. Please set your API key using the "TinyPNG: Set API Key" command.',
'Set API Key'
).then(selection => {
if (selection === 'Set API Key') {
vscode.commands.executeCommand('extension.setApiKey');
}
});
}
9 changes: 8 additions & 1 deletion test/suite/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ suite('TinyPNG Extension Test Suite', () => {
assert.ok(commands.includes('extension.compressGitStage'));
});

test('Should register setApiKey command', async () => {
const commands = await vscode.commands.getCommands(true);
assert.ok(commands.includes('extension.setApiKey'));
});

test('Configuration should have apiKey property', () => {
const config = vscode.workspace.getConfiguration('tinypng');
assert.ok(config.has('apiKey'));
Expand Down Expand Up @@ -134,20 +139,22 @@ suite('TinyPNG Extension Test Suite', () => {
assert.ok(activationEvents.includes('onCommand:extension.compressFile'));
assert.ok(activationEvents.includes('onCommand:extension.compressFolder'));
assert.ok(activationEvents.includes('onCommand:extension.getCompressionCount'));
assert.ok(activationEvents.includes('onCommand:extension.setApiKey'));
});

test('Extension should contribute commands to command palette', () => {
const ext = vscode.extensions.getExtension('andi1984.tinypng');
assert.ok(ext);

const commands = ext!.packageJSON.contributes.commands;
assert.ok(commands.length >= 4);
assert.ok(commands.length >= 5);

const commandTitles = commands.map((cmd: any) => cmd.command);
assert.ok(commandTitles.includes('extension.compressFile'));
assert.ok(commandTitles.includes('extension.compressFolder'));
assert.ok(commandTitles.includes('extension.getCompressionCount'));
assert.ok(commandTitles.includes('extension.compressGitStage'));
assert.ok(commandTitles.includes('extension.setApiKey'));
});

test('Extension should have explorer context menu contributions', () => {
Expand Down
6 changes: 4 additions & 2 deletions test/suite/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ suite('Integration Tests', () => {
'extension.compressFile',
'extension.compressFolder',
'extension.getCompressionCount',
'extension.compressGitStage'
'extension.compressGitStage',
'extension.setApiKey'
];

extensionCommands.forEach(cmd => {
Expand Down Expand Up @@ -152,7 +153,8 @@ suite('Integration Tests', () => {
const expectedEvents = [
'onCommand:extension.compressFile',
'onCommand:extension.compressFolder',
'onCommand:extension.getCompressionCount'
'onCommand:extension.getCompressionCount',
'onCommand:extension.setApiKey'
];

expectedEvents.forEach(event => {
Expand Down
Loading
Loading