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
11 changes: 9 additions & 2 deletions src/cli/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,17 @@ async function checkWritable(dir: string): Promise<boolean> {
}
}

/** A response, even an error one, means the network and TLS are fine. */
/**
* A response, even an error one, means the network and TLS are fine.
*
* HEAD rather than GET: this only asks whether the host is reachable, not
* for anything in the body, so there's no reason to download one — even an
* error status or a 405 for HEAD itself still proves the network and TLS
* are fine, which is all this checks.
*/
async function checkReachable(url: string): Promise<boolean> {
try {
await http.get(url, { throwHttpErrors: false })
await http.head(url, { throwHttpErrors: false })
return true
} catch {
return false
Expand Down
5 changes: 4 additions & 1 deletion src/http/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface RequestOptions {
export interface HttpClient {
get(url: string, options?: RequestOptions): Promise<Response>
post(url: string, options?: RequestOptions): Promise<Response>
/** HEADs `url` — the same reachability signal as `get`, without downloading the body. */
head(url: string, options?: RequestOptions): Promise<Response>
/** GETs `url` and reads the body into a `Buffer` — the shape every asset fetcher needs. */
getBuffer(url: string, signal?: AbortSignal): Promise<Buffer>
}
Expand Down Expand Up @@ -61,7 +63,7 @@ export function createClient(options: HttpOptions = {}): HttpClient {
})

async function request(
method: 'GET' | 'POST',
method: 'GET' | 'HEAD' | 'POST',
url: string,
options: RequestOptions,
): Promise<Response> {
Expand Down Expand Up @@ -105,6 +107,7 @@ export function createClient(options: HttpOptions = {}): HttpClient {

return {
get,
head: (url, options = {}) => request('HEAD', url, options),
post: (url, options = {}) => request('POST', url, options),
getBuffer: async (url, signal) => {
const response = await get(url, signal ? { signal } : {})
Expand Down
39 changes: 38 additions & 1 deletion src/render/avatar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AvatarFetcher } from './avatar'
import { loadAvatar } from './avatar'
import {
drawAvatar,
loadAvatar,
resetFilterDetectionForTests,
setFilterSupportedForTests,
} from './avatar'
import { avatarCacheInfo, clearAvatarCache, configureAvatarCache } from './avatarCache'
import { createCanvas } from './canvasFactory'

Expand Down Expand Up @@ -148,3 +153,35 @@ describe('loadAvatar', () => {
expect(fetcher.calls).toHaveLength(2)
})
})

describe('drawAvatar grayscale fallback (no ctx.filter support)', () => {
afterEach(() => {
resetFilterDetectionForTests()
})

it('desaturates the whole painted box, including its fractional far edge', async () => {
setFilterSupportedForTests(false)

const canvas = createCanvas(5, 5)
const ctx = canvas.getContext('2d')
const image = await loadAvatar(redSquare())

drawAvatar(ctx, image, {
theme: {
grayscale: true,
position: 'left',
widthRatio: 1,
fit: 'cover',
shape: 'rectangle',
fallback: null,
},
// A non-integer box: painting/desaturating must cover pixel (4, 4) too,
// not just the 4x4 region a truncated width/height would leave.
box: { x: 0, y: 0, width: 4.9, height: 4.9 },
})

const { data } = ctx.getImageData(4, 4, 1, 1)
expect(data[0]).toBe(data[1])
expect(data[1]).toBe(data[2])
})
})
23 changes: 20 additions & 3 deletions src/render/avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,17 +158,29 @@ function luma(r: number, g: number, b: number): number {
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}

/** Desaturates a region in place, for when `ctx.filter` isn't available. */
/**
* Desaturates a region in place, for when `ctx.filter` isn't available.
*
* `box` comes from ratio-based layout math and is rarely integer-aligned;
* `getImageData`/`putImageData` need integer pixels, so this rounds outward
* (floor the origin, ceil the far edge) rather than truncating, to always
* cover the whole painted area instead of clipping a row or column of it.
*/
function desaturateRegion(ctx: SKRSContext2D, box: AvatarBox): void {
const image = ctx.getImageData(box.x, box.y, box.width, box.height)
const x = Math.floor(box.x)
const y = Math.floor(box.y)
const width = Math.ceil(box.x + box.width) - x
const height = Math.ceil(box.y + box.height) - y

const image = ctx.getImageData(x, y, width, height)
const { data } = image
for (let i = 0; i < data.length; i += 4) {
const value = luma(data[i] as number, data[i + 1] as number, data[i + 2] as number)
data[i] = value
data[i + 1] = value
data[i + 2] = value
}
ctx.putImageData(image, box.x, box.y)
ctx.putImageData(image, x, y)
}

export interface DrawAvatarOptions {
Expand Down Expand Up @@ -244,3 +256,8 @@ export function drawAvatar(
export function resetFilterDetectionForTests(): void {
filterSupported = null
}

/** Test seam: forces `supportsFilter()`'s result, to exercise the fallback path deterministically. */
export function setFilterSupportedForTests(value: boolean): void {
filterSupported = value
}
52 changes: 52 additions & 0 deletions src/util/version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { isNewerVersion } from './version'

describe('isNewerVersion', () => {
it('is true when the minor version increases', () => {
expect(isNewerVersion('9.0.1', '9.1.0')).toBe(true)
})

it('is false when the versions are equal', () => {
expect(isNewerVersion('9.1.0', '9.1.0')).toBe(false)
})

it('is false when latest is older', () => {
expect(isNewerVersion('9.1.0', '9.0.1')).toBe(false)
})

it('treats a missing segment as zero', () => {
expect(isNewerVersion('1.0', '1.0.1')).toBe(true)
expect(isNewerVersion('1.0.1', '1.0')).toBe(false)
})

it('strips a leading v so tag styles compare the same way', () => {
expect(isNewerVersion('v30', 'v31')).toBe(true)
expect(isNewerVersion('30', 'v31')).toBe(true)
})

it('compares left to right by numeric value, not lexically', () => {
expect(isNewerVersion('1.9.0', '1.10.0')).toBe(true)
})

it('falls back to treating an unparseable segment as zero', () => {
// "not-a-version" has no digits at all, so every segment is 0 — the same
// as comparing against "0.0.0".
expect(isNewerVersion('1.0.0', 'not-a-version')).toBe(false)
expect(isNewerVersion('not-a-version', '1.0.0')).toBe(true)
})

it('does not treat a prerelease as newer than the release it precedes', () => {
expect(isNewerVersion('12.0.0', '12.0.0-rc.1')).toBe(false)
})

it('does not treat a release as newer than its own prerelease', () => {
// Not full semver — a prerelease and its release compare as equal here —
// but it must never flip in the wrong direction (see the previous case).
expect(isNewerVersion('12.0.0-rc.1', '12.0.0')).toBe(false)
})

it('ignores build metadata the same way', () => {
expect(isNewerVersion('1.2.3+build5', '1.2.3')).toBe(false)
expect(isNewerVersion('1.2.3', '1.2.3+build5')).toBe(false)
})
})
13 changes: 11 additions & 2 deletions src/util/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,17 @@ export function isNewerVersion(current: string, latest: string): boolean {
}

function toSegments(version: string): number[] {
return version
.replace(/^v/, '')
// Drop any prerelease/build suffix (`-rc.1`, `+build5`) before splitting on
// `.`: left in, "12.0.0-rc.1" parsed as [12, 0, 0, 1] — parseInt truncates
// "0-rc" down to the digits it starts with, so the trailing ".1" became a
// real extra segment, making the prerelease compare as *newer* than the
// release it precedes. Comparing bare cores instead can't tell a
// prerelease apart from its release, but that's a smaller, safer
// imprecision than the reversal — and every real caller here (npm
// dist-tags, Twemoji/Google Fonts release tags) is a plain core version.
const core = version.replace(/^v/, '').split(/[-+]/)[0] as string

return core
.split('.')
.map((part) => Number.parseInt(part, 10))
.map((n) => (Number.isFinite(n) ? n : 0))
Expand Down
Loading