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: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- sharp, which converts and resizes the pasted image, 0.35.4 → 0.35.5. It ships
libvips 8.18.7, up from 8.18.6, with newer builds of the libraries libvips
uses.
- `@kkdev92/vscode-ext-kit` `^7.0.0` → `^7.1.0`. Its log filter and its check
for relative paths replace ClipShot's own.
- ClipShot no longer sets the `clipshot.enabled` context key. Nothing in
ClipShot read it; a keybinding of your own can test the setting as
`config.clipshot.enabled`.
- A save directory starting with a drive letter, such as `C:images`, or with a
backslash is now reported in the log as not relative on every platform, as
one starting with `/` is. Where images are saved is unchanged.

## [0.9.1] - 2026-09-28

### Changed
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@kkdev92/vscode-ext-kit": "^7.0.0",
"@kkdev92/vscode-ext-kit": "^7.1.0",
"@types/mocha": "^10.0.10",
"@types/node": "^22.20.4",
"@types/vscode": "~1.138.0",
Expand Down
17 changes: 8 additions & 9 deletions src/config/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Validates user settings to prevent security issues
*/

import { checkRelativePath } from '@kkdev92/vscode-ext-kit';

import type { ValidationResult, ExtensionConfig, DeepPartial } from '../core/types';
import {
LIMITS,
Expand All @@ -12,7 +14,6 @@ import {
VALID_RESIZE_MODES,
RESIZE_PRESETS,
} from '../core/constants';
import { containsParentTraversal, isAbsolutePath } from '../security/path-validator';

/**
* Validate the saveDirectory setting
Expand All @@ -23,19 +24,17 @@ import { containsParentTraversal, isAbsolutePath } from '../security/path-valida
export function validateSaveDirectory(value: string): ValidationResult {
const errors: string[] = [];

// Check for empty value
if (!value || value.trim() === '') {
// Empty, rooted (a drive, a leading separator, a share) or climbing out
// with `..`, judged the same way whichever platform the value was written on.
const problem = checkRelativePath(value);
if (problem === 'empty') {
errors.push('Save directory cannot be empty');
return { valid: false, errors };
}

// Check for absolute path
if (isAbsolutePath(value)) {
if (problem === 'absolute') {
errors.push('Save directory must be a relative path');
}

// Check for parent directory traversal
if (containsParentTraversal(value)) {
if (problem === 'parent') {
errors.push('Save directory cannot contain parent directory references (..)');
}

Expand Down
7 changes: 0 additions & 7 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,6 @@ export const COMMANDS = {
ENABLE_IN_TERMINAL: `${EXTENSION_ID}.enableInTerminal`,
} as const;

/**
* Context keys for VS Code when clause
*/
export const CONTEXT_KEYS = {
ENABLED: `${EXTENSION_ID}.enabled`,
} as const;

/**
* Configuration key prefix
*/
Expand Down
159 changes: 35 additions & 124 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,25 @@
* safety live where they always did; this file is only how they are reached.
*/

import * as vscode from 'vscode';

import {
Notifications,
defineCommandContract,
defineExtension,
defineModule,
filterLogger,
type OperationContext,
type Validator,
} from '@kkdev92/vscode-ext-kit';

import { Settings, loadConfiguration } from './config/schema';
import { validateConfiguration } from './config/validators';
import { COMMANDS, CONTEXT_KEYS, EXTENSION_NAME } from './core/constants';
import { COMMANDS, EXTENSION_NAME } from './core/constants';
import { disposeGlobalClipboardManager } from './clipboard/clipboard-manager';
import { getPasteHandler } from './keyboard/paste-handler';
import { describeSkipShellOutcome, ensureSkipShellEntry } from './terminal/skip-shell';
import { disposeGlobalTempFileManager } from './security/temp-file-manager';
import type {
ExtensionConfig,
LogLevel,
Logger,
NotificationLevel,
PasteDestination,
Expand Down Expand Up @@ -88,61 +87,6 @@ export const EnableInTerminal = defineCommandContract<readonly [], void>({
id: COMMANDS.ENABLE_IN_TERMINAL,
});

/** Severity order, for comparing against the configured floor. */
const SEVERITY: Record<Exclude<LogLevel, 'silent'>, number> = {
trace: 0,
debug: 1,
info: 2,
warn: 3,
error: 4,
};

/**
* Applies `clipshot.logLevel` on top of the channel's own level.
*
* The framework logs into a `LogOutputChannel`, which VS Code filters by the
* level chosen in the Output panel — so unlike before, this setting can only
* make the log quieter, never louder. It is kept because "stop logging at all"
* (`silent`) and "only warnings and worse" are things a user asks for and the
* panel's dropdown is easy to miss; what it can no longer do is turn on `debug`
* output that VS Code is filtering out one level up.
*/
function filtered(logger: Logger, level: LogLevel): Logger {
if (level === 'trace') {
return logger;
}
const floor = level === 'silent' ? Number.POSITIVE_INFINITY : SEVERITY[level];
const passes = (of: Exclude<LogLevel, 'silent'>): boolean => SEVERITY[of] >= floor;
return {
trace: (message, fields): void => {
if (passes('trace')) {
logger.trace(message, fields);
}
},
debug: (message, fields): void => {
if (passes('debug')) {
logger.debug(message, fields);
}
},
info: (message, fields): void => {
if (passes('info')) {
logger.info(message, fields);
}
},
warn: (message, fields): void => {
if (passes('warn')) {
logger.warn(message, fields);
}
},
error: (message, error, fields): void => {
if (passes('error')) {
logger.error(message, error, fields);
}
},
withFields: (fields): Logger => filtered(logger.withFields(fields), level),
};
}

/** Logs every configuration problem as a warning, without refusing to run. */
function warnAboutConfig(config: ExtensionConfig, logger: Logger): void {
const result = validateConfiguration(config);
Expand All @@ -153,32 +97,6 @@ function warnAboutConfig(config: ExtensionConfig, logger: Logger): void {
}
}

/**
* Mirrors `enabled` into a context key.
*
* `setContext` is the only way a `when` clause in package.json can see a
* setting, and there is no capability for it — a command is how VS Code
* exposes it, so this is the one place the extension calls one directly.
*
* A failure here is reported and swallowed rather than propagated. The caller
* is a settings-change listener and an activation path, and neither has a way
* to act on it: the extension is still usable with a stale `when` clause, and
* failing activation over a menu item's visibility would be the worse outcome.
* It matters in practice because `setContext` is a VS Code built-in rather than
* something this extension registers, so a test double that only knows
* registered commands rejects it.
*/
async function publishContextKeys(config: ExtensionConfig, logger: Logger): Promise<void> {
try {
await vscode.commands.executeCommand('setContext', CONTEXT_KEYS.ENABLED, config.enabled);
} catch (error) {
logger.debug('Could not publish the context key', {
key: CONTEXT_KEYS.ENABLED,
reason: error instanceof Error ? error.message : String(error),
});
}
}

/** Whether a notification of this kind should be shown at all. */
function wants(level: NotificationLevel, kind: 'success' | 'error'): boolean {
return kind === 'success' ? level === 'all' : level !== 'none';
Expand Down Expand Up @@ -233,7 +151,9 @@ export const clipshot = defineModule('clipshot', (module): undefined => {
inject: { settings: Settings.token },
execute: async (context: OperationContext, [args], { settings }): Promise<void> => {
const config = loadConfiguration(settings);
const logger = filtered(context.logger, config.logLevel);
// clipshot.logLevel is a floor on top of the channel's own level: VS Code
// owns that level, and an extension cannot raise it.
const logger = filterLogger(context.logger, config.logLevel);

// Read per invocation rather than held from activation: a setting the
// user changed a moment ago should apply to this paste, and the accessor
Expand Down Expand Up @@ -270,7 +190,7 @@ export const clipshot = defineModule('clipshot', (module): undefined => {
inject: { settings: Settings.token },
execute: async (context: OperationContext, _args, { settings }): Promise<void> => {
const config = loadConfiguration(settings);
const logger = filtered(context.logger, config.logLevel);
const logger = filterLogger(context.logger, config.logLevel);
const outcome = await ensureSkipShellEntry(COMMANDS.PASTE_IMAGE, logger);
const message = describeSkipShellOutcome(outcome);
// Run by hand, so the result is reported whatever it is and whatever the
Expand All @@ -286,33 +206,28 @@ export const clipshot = defineModule('clipshot', (module): undefined => {
});

// Configuration is read where it is used, so nothing here caches it. What
// this service exists for is the two effects a change has outside a paste:
// the context key a `when` clause reads, and the warnings.
let subscription: { dispose(): void } | undefined;
// this service exists for is the one effect a change has outside a paste:
// the warnings.
module.hostedServices.add({
id: 'clipshot.configuration',
inject: { settings: Settings.token },
start: async (context, { settings }) => {
const apply = async (): Promise<void> => {
start: (context, { settings }) => {
const apply = (): void => {
const config = loadConfiguration(settings);
const logger = filtered(context.logger, config.logLevel);
warnAboutConfig(config, logger);
await publishContextKeys(config, logger);
warnAboutConfig(config, filterLogger(context.logger, config.logLevel));
};
// Awaited here so the context key is set before activation reports done;
// `publishContextKeys` swallows its own failure, so this cannot reject.
await apply();
apply();
// `onDidChange` fires for the section as a whole. Every key here feeds
// either the context key or the warnings, so there is nothing to filter
// on — `watch` per key would be sixteen subscriptions doing one job.
subscription = settings.onDidChange(() => {
// the warnings, so there is nothing to filter on — `watch` per key would
// be sixteen subscriptions doing one job.
const subscription = settings.onDidChange(() => {
context.logger.info('Configuration updated');
void apply();
apply();
});
// Released through the signal, which aborts however the application ends.
context.signal.addEventListener('abort', () => {
subscription.dispose();
});
},
stop: () => {
subscription?.dispose();
subscription = undefined;
},
});

Expand All @@ -321,11 +236,10 @@ export const clipshot = defineModule('clipshot', (module): undefined => {
// contribution point for that list — the reasoning, and why this only started
// mattering, is in src/terminal/skip-shell.ts. Activation is where the write
// belongs: it is the last moment before a user reaches for the shortcut.
let terminalSubscription: { dispose(): void } | undefined;
module.hostedServices.add({
id: 'clipshot.terminalShortcut',
inject: { settings: Settings.token },
start: async (context, { settings }) => {
inject: { settings: Settings.token, notifications: Notifications },
start: async (context, { settings, notifications }) => {
// Settled once the list has an answer in it. 'failed' is the one outcome
// left unsettled: a settings.json that could not be written now may be
// writable later, and a change event is a reasonable moment to retry.
Expand All @@ -335,31 +249,28 @@ export const clipshot = defineModule('clipshot', (module): undefined => {
if (settled || !config.terminal.registerShortcut) {
return;
}
const logger = filtered(context.logger, config.logLevel);
const logger = filterLogger(context.logger, config.logLevel);
const outcome = await ensureSkipShellEntry(COMMANDS.PASTE_IMAGE, logger);
settled = outcome !== 'failed';
if (outcome === 'added' && wants(config.notifications.level, 'success')) {
// A hosted service carries a logger and no UI — deliberately, since
// VS Code may be shutting down when one stops. Writing to a user's
// settings without telling them is the worse trade, so this is the
// second and last place the extension reaches for a VS Code API by
// hand. It fires once in the life of an installation.
announce(
Promise.resolve(vscode.window.showInformationMessage(describeSkipShellOutcome(outcome))),
logger
);
// Writing to a user's settings without telling them is the worse
// trade. It fires once in the life of an installation.
announce(notifications.info(describeSkipShellOutcome(outcome)), logger);
}
};
await apply();
// Stopped while that ran: a subscription made now would outlive the stop.
if (context.signal.aborted) {
return;
}
// Turning the setting on is a request, not a preference to note for next
// time, so it is acted on when it happens rather than at the next start.
terminalSubscription = settings.onDidChange(() => {
const subscription = settings.onDidChange(() => {
void apply();
});
},
stop: () => {
terminalSubscription?.dispose();
terminalSubscription = undefined;
context.signal.addEventListener('abort', () => {
subscription.dispose();
});
},
});

Expand Down
Loading
Loading