feat(desktop): loopback reach for the in-app browser over an SSH gateway
Opens a local->remote forward on demand so a gateway-side dev server URL resolves to the gateway, not the laptop rendering the webview. One lease per remote port, reused across pages and expired after 15 minutes; the port allowlist is deliberately absent, since the transport we are already authenticated on is the security boundary and a curated list just means the next framework default silently fails. Lease and capability shape adapted from #87243. Co-authored-by: tuancookiez-hub <tuancookiez-hub@users.noreply.github.com>
This commit is contained in:
committed by
brooklyn!
parent
2d579af869
commit
0600738f6d
@@ -0,0 +1,135 @@
|
||||
// E2E: the real preview-reach module against a real remote host.
|
||||
//
|
||||
// Not a unit test — this spawns actual `ssh -N -L` tunnels to a live box and
|
||||
// fetches a dev server that is bound to THAT machine's loopback and is
|
||||
// provably unreachable from here. Run manually:
|
||||
// node --experimental-strip-types electron/preview-reach.e2e.mts <user@host>
|
||||
import http from 'node:http'
|
||||
import { spawn } from 'node:child_process'
|
||||
import net from 'node:net'
|
||||
|
||||
import { loopbackTarget, PreviewReachRegistry, rewriteToLocalPort } from './preview-reach.ts'
|
||||
|
||||
const HOST = process.argv[2] || 'root@5.161.224.47'
|
||||
const REMOTE_PORT = 5173
|
||||
|
||||
function pickLocalPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.unref()
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address() as net.AddressInfo
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const tunnels = new Map<string, ReturnType<typeof spawn>>()
|
||||
|
||||
async function forward(localPort: number, remotePort: number, remoteHost = '127.0.0.1') {
|
||||
const spec = `127.0.0.1:${localPort}:${remoteHost}:${remotePort}`
|
||||
const child = spawn(
|
||||
'ssh',
|
||||
['-o', 'BatchMode=yes', '-o', 'ExitOnForwardFailure=yes', '-v', '-N', '-L', spec, '--', HOST],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] }
|
||||
)
|
||||
|
||||
tunnels.set(spec, child)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('forward timed out')), 15_000)
|
||||
child.stderr?.on('data', d => {
|
||||
if (new RegExp(`Local forwarding listening on .* port ${localPort}\\b`).test(String(d))) {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
child.on('exit', code => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error(`ssh exited ${code}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function cancel(localPort: number, remotePort: number) {
|
||||
const spec = `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`
|
||||
tunnels.get(spec)?.kill()
|
||||
tunnels.delete(spec)
|
||||
}
|
||||
|
||||
function get(url: string, timeoutMs = 6000): Promise<{ body: string; status: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(url, res => {
|
||||
let body = ''
|
||||
res.on('data', c => (body += c))
|
||||
res.on('end', () => resolve({ body, status: res.statusCode || 0 }))
|
||||
})
|
||||
req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout')))
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
const results: string[] = []
|
||||
const check = (name: string, ok: boolean, detail = '') => {
|
||||
results.push(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
|
||||
}
|
||||
|
||||
const registry = new PreviewReachRegistry()
|
||||
const deps = { cancel, forward, isCurrent: () => true, pickLocalPort }
|
||||
const REMOTE_URL = `http://localhost:${REMOTE_PORT}/`
|
||||
|
||||
try {
|
||||
// 0. The bug itself: the agent's URL must be dead on this machine.
|
||||
let unreachable = false
|
||||
try {
|
||||
await get(REMOTE_URL, 4000)
|
||||
} catch {
|
||||
unreachable = true
|
||||
}
|
||||
check('agent URL is unreachable locally (the bug)', unreachable)
|
||||
|
||||
// 1. Reach it.
|
||||
const reached = await registry.resolve(REMOTE_URL, deps)
|
||||
check('resolve() returns a rewritten URL', Boolean(reached) && reached !== REMOTE_URL, String(reached))
|
||||
|
||||
const page = await get(reached!)
|
||||
check('rewritten URL serves the REMOTE page', page.status === 200 && page.body.includes('REMOTE box'), `status=${page.status}`)
|
||||
|
||||
// 2. Same remote port reuses one tunnel; the path must survive the rewrite.
|
||||
const second = await registry.resolve(`http://localhost:${REMOTE_PORT}/index.html?q=1#frag`, deps)
|
||||
check('same port reuses one lease', registry.size === 1, `size=${registry.size}`)
|
||||
check('path/query/hash preserved', second!.endsWith('/index.html?q=1#frag'), String(second))
|
||||
|
||||
const deep = await get(second!.split('#')[0])
|
||||
check('deep URL still serves the remote page', deep.status === 200 && deep.body.includes('REMOTE box'))
|
||||
|
||||
// 3. Non-loopback is left alone — no tunnel to a public host.
|
||||
const external = await registry.resolve('https://example.com/x', deps)
|
||||
check('non-loopback returns null', external === null, String(external))
|
||||
|
||||
// 4. Teardown actually closes the socket.
|
||||
const port = Number(new URL(reached!).port)
|
||||
await registry.closeAll()
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
let closed = false
|
||||
try {
|
||||
await get(`http://127.0.0.1:${port}/`, 2500)
|
||||
} catch {
|
||||
closed = true
|
||||
}
|
||||
check('closeAll() tears the forward down', closed && registry.size === 0)
|
||||
|
||||
// 5. Pure helpers agree with the live behavior.
|
||||
check('loopbackTarget parses ipv6 loopback', loopbackTarget('http://[::1]:5173/')?.port === 5173)
|
||||
check('loopbackTarget rejects public host', loopbackTarget('http://example.com:5173/') === null)
|
||||
check('rewriteToLocalPort forces http', rewriteToLocalPort('https://localhost:5173/a', 42).startsWith('http://127.0.0.1:42/a'))
|
||||
} finally {
|
||||
for (const child of tunnels.values()) {
|
||||
child.kill()
|
||||
}
|
||||
}
|
||||
|
||||
console.log(results.join('\n'))
|
||||
console.log(results.some(r => r.startsWith('FAIL')) ? '\nE2E FAILED' : '\nE2E PASSED')
|
||||
process.exit(results.some(r => r.startsWith('FAIL')) ? 1 : 0)
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
loopbackTarget,
|
||||
openPreviewReach,
|
||||
PREVIEW_REACH_LEASE_MS,
|
||||
PreviewReachRegistry,
|
||||
rewriteToLocalPort
|
||||
} from './preview-reach'
|
||||
|
||||
function deps(overrides: Partial<Parameters<typeof openPreviewReach>[1]> = {}) {
|
||||
return {
|
||||
cancel: vi.fn(async () => {}),
|
||||
forward: vi.fn(async () => {}),
|
||||
isCurrent: () => true,
|
||||
pickLocalPort: vi.fn(async () => 45_173),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('loopbackTarget', () => {
|
||||
it.each([
|
||||
['http://localhost:5173/', 5173],
|
||||
['http://127.0.0.1:3000/app', 3000],
|
||||
['http://0.0.0.0:8080', 8080],
|
||||
['http://[::1]:4321/x', 4321]
|
||||
])('recognizes %s', (url, port) => {
|
||||
expect(loopbackTarget(url)?.port).toBe(port)
|
||||
})
|
||||
|
||||
// An allowlist of "known" dev ports just means the next framework's default
|
||||
// silently fails; the transport is the security boundary, not the port.
|
||||
it('accepts any port, not a curated list', () => {
|
||||
expect(loopbackTarget('http://localhost:61234/')?.port).toBe(61_234)
|
||||
})
|
||||
|
||||
it('defaults the port by scheme', () => {
|
||||
expect(loopbackTarget('http://localhost/')?.port).toBe(80)
|
||||
expect(loopbackTarget('https://localhost/')?.port).toBe(443)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a public host', 'https://example.com/x'],
|
||||
['a lookalike subdomain', 'http://localhost.evil.com/'],
|
||||
['a non-web scheme', 'file:///etc/passwd'],
|
||||
['junk', 'not a url']
|
||||
])('rejects %s', (_name, url) => {
|
||||
expect(loopbackTarget(url)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteToLocalPort', () => {
|
||||
it('keeps path, query and hash — they are what make the URL useful', () => {
|
||||
expect(rewriteToLocalPort('http://localhost:5173/a/b?q=1#f', 42)).toBe('http://127.0.0.1:42/a/b?q=1#f')
|
||||
})
|
||||
|
||||
// The forward carries plain TCP to a dev server that is almost never
|
||||
// TLS-terminated; keeping https would fail the handshake.
|
||||
it('forces http', () => {
|
||||
expect(rewriteToLocalPort('https://localhost:5173/', 42)).toBe('http://127.0.0.1:42/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openPreviewReach', () => {
|
||||
it('forwards the remote port and returns the local URL', async () => {
|
||||
const d = deps()
|
||||
const lease = await openPreviewReach('http://localhost:5173/app', d)
|
||||
|
||||
expect(d.forward).toHaveBeenCalledWith(45_173, 5173, '127.0.0.1')
|
||||
expect(lease?.url).toBe('http://127.0.0.1:45173/app')
|
||||
expect(lease?.expiresAt).toBeGreaterThan(Date.now())
|
||||
expect(lease?.expiresAt).toBeLessThanOrEqual(Date.now() + PREVIEW_REACH_LEASE_MS)
|
||||
})
|
||||
|
||||
it('ignores a non-loopback URL without opening anything', async () => {
|
||||
const d = deps()
|
||||
|
||||
expect(await openPreviewReach('https://example.com/', d)).toBeNull()
|
||||
expect(d.forward).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The connection can die between picking a port and using it; forwarding
|
||||
// then would tunnel into whatever host replaced it.
|
||||
it('bails when the authorizing connection went away mid-open', async () => {
|
||||
const d = deps({ isCurrent: () => false })
|
||||
|
||||
expect(await openPreviewReach('http://localhost:5173/', d)).toBeNull()
|
||||
expect(d.forward).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a failed forward instead of pretending it worked', async () => {
|
||||
const d = deps({
|
||||
forward: vi.fn(async () => {
|
||||
throw new Error('ssh exited 255')
|
||||
})
|
||||
})
|
||||
|
||||
await expect(openPreviewReach('http://localhost:5173/', d)).rejects.toThrow('ssh exited 255')
|
||||
})
|
||||
|
||||
it('cancels exactly once however many times close is called', async () => {
|
||||
const d = deps()
|
||||
const lease = await openPreviewReach('http://localhost:5173/', d)
|
||||
|
||||
await lease!.close()
|
||||
await lease!.close()
|
||||
|
||||
expect(d.cancel).toHaveBeenCalledExactlyOnceWith(45_173, 5173)
|
||||
})
|
||||
|
||||
it('closes itself when the lease expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const d = deps()
|
||||
|
||||
await openPreviewReach('http://localhost:5173/', d)
|
||||
expect(d.cancel).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(PREVIEW_REACH_LEASE_MS + 1)
|
||||
expect(d.cancel).toHaveBeenCalledWith(45_173, 5173)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('PreviewReachRegistry', () => {
|
||||
// Navigating a dev server is the same tunnel; a lease per page would leak a
|
||||
// socket per click.
|
||||
it('reuses one lease per remote port across pages', async () => {
|
||||
const registry = new PreviewReachRegistry()
|
||||
const d = deps()
|
||||
|
||||
const first = await registry.resolve('http://localhost:5173/', d)
|
||||
const second = await registry.resolve('http://localhost:5173/about?x=1', d)
|
||||
|
||||
expect(d.forward).toHaveBeenCalledOnce()
|
||||
expect(registry.size).toBe(1)
|
||||
expect(first).toBe('http://127.0.0.1:45173/')
|
||||
expect(second).toBe('http://127.0.0.1:45173/about?x=1')
|
||||
})
|
||||
|
||||
it('opens a separate lease per distinct remote port', async () => {
|
||||
const registry = new PreviewReachRegistry()
|
||||
let next = 46_000
|
||||
const d = deps({ pickLocalPort: vi.fn(async () => (next += 1)) })
|
||||
|
||||
await registry.resolve('http://localhost:5173/', d)
|
||||
await registry.resolve('http://localhost:3000/', d)
|
||||
|
||||
expect(registry.size).toBe(2)
|
||||
expect(d.forward).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('replaces an expired lease rather than serving a dead port', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const registry = new PreviewReachRegistry()
|
||||
let next = 47_000
|
||||
const d = deps({ pickLocalPort: vi.fn(async () => (next += 1)) })
|
||||
|
||||
const first = await registry.resolve('http://localhost:5173/', d)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(PREVIEW_REACH_LEASE_MS + 1)
|
||||
|
||||
const second = await registry.resolve('http://localhost:5173/', d)
|
||||
|
||||
expect(second).not.toBe(first)
|
||||
expect(d.forward).toHaveBeenCalledTimes(2)
|
||||
expect(registry.size).toBe(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('closeAll tears down every forward — the connection changed hosts', async () => {
|
||||
const registry = new PreviewReachRegistry()
|
||||
let next = 48_000
|
||||
const d = deps({ pickLocalPort: vi.fn(async () => (next += 1)) })
|
||||
|
||||
await registry.resolve('http://localhost:5173/', d)
|
||||
await registry.resolve('http://localhost:3000/', d)
|
||||
await registry.closeAll()
|
||||
|
||||
expect(d.cancel).toHaveBeenCalledTimes(2)
|
||||
expect(registry.size).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves a non-loopback URL alone', async () => {
|
||||
const registry = new PreviewReachRegistry()
|
||||
const d = deps()
|
||||
|
||||
expect(await registry.resolve('https://example.com/', d)).toBeNull()
|
||||
expect(registry.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Loopback reach for the in-app browser against a REMOTE gateway.
|
||||
*
|
||||
* The `<webview>` renders on the user's machine. The agent runs on the gateway
|
||||
* host. So when the agent says "your dev server is at http://localhost:5173",
|
||||
* that address is true *there* and meaningless *here* — locally it is usually
|
||||
* nothing at all, and occasionally somebody else's service on the same port.
|
||||
*
|
||||
* The fix is to make the address true here too: open a local→remote forward
|
||||
* over the transport we are ALREADY authenticated on, and hand the renderer a
|
||||
* `127.0.0.1:<localPort>` URL that lands on the gateway's port. No new
|
||||
* credentials, no new listening surface beyond one loopback-bound port.
|
||||
*
|
||||
* Only SSH-backed remotes can do this today: a `url`/`cloud` gateway is an HTTP
|
||||
* endpoint with no tunnel to borrow, so there is nothing to forward through.
|
||||
* Those callers get `null` and the pane keeps explaining the mismatch instead
|
||||
* of failing silently — see `isRemoteLoopbackUrl` in preview-pane.tsx.
|
||||
*
|
||||
* The lease/capability shape here is adapted from tuancookiez-hub's #87243,
|
||||
* which solved the same problem for Windows SSH previews.
|
||||
*/
|
||||
|
||||
/** Hosts that mean "the machine this resolved on" — the whole problem class. */
|
||||
const LOOPBACK_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', 'localhost'])
|
||||
|
||||
/** A forward is a live socket to someone else's machine; it should not outlive
|
||||
* the user's attention on it. Refreshed on every reuse. */
|
||||
export const PREVIEW_REACH_LEASE_MS = 15 * 60 * 1000
|
||||
|
||||
export interface PreviewReachDeps {
|
||||
/** Tear the forward down. */
|
||||
cancel: (localPort: number, remotePort: number) => Promise<void>
|
||||
/** Open it. Mirrors `SshConnection.forward`. */
|
||||
forward: (localPort: number, remotePort: number, remoteHost?: string) => Promise<void>
|
||||
/** False once the connection that authorized this lease is gone. */
|
||||
isCurrent: () => boolean
|
||||
/** Kernel-assigned free loopback port. */
|
||||
pickLocalPort: () => Promise<number>
|
||||
}
|
||||
|
||||
export interface PreviewReachLease {
|
||||
close: () => Promise<void>
|
||||
expiresAt: number
|
||||
localPort: number
|
||||
remoteHost: string
|
||||
remotePort: number
|
||||
/** The address to hand the renderer. */
|
||||
url: string
|
||||
}
|
||||
|
||||
interface ParsedLoopback {
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The loopback target inside `rawUrl`, or null when the URL isn't one.
|
||||
*
|
||||
* Deliberately permissive about the port: a dev server is whatever the
|
||||
* framework picked (5173, 3000, 8080, 4321, …), and an allowlist just means
|
||||
* the next framework's default silently fails. The security boundary is the
|
||||
* transport — we can only ever reach a host we are already authenticated to —
|
||||
* not a guess about which ports are wholesome.
|
||||
*/
|
||||
export function loopbackTarget(rawUrl: string): null | ParsedLoopback {
|
||||
let parsed: URL
|
||||
|
||||
try {
|
||||
parsed = new URL(rawUrl)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
||||
|
||||
if (!LOOPBACK_HOSTS.has(host)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const port = Number(parsed.port || (parsed.protocol === 'https:' ? 443 : 80))
|
||||
|
||||
return Number.isInteger(port) && port > 0 && port < 65_536 ? { host, port } : null
|
||||
}
|
||||
|
||||
/** Swap the authority for the local end of a forward, preserving everything
|
||||
* else. Path, query, and hash are what make the URL useful. */
|
||||
export function rewriteToLocalPort(rawUrl: string, localPort: number): string {
|
||||
const parsed = new URL(rawUrl)
|
||||
|
||||
parsed.hostname = '127.0.0.1'
|
||||
parsed.port = String(localPort)
|
||||
// The forward carries plain TCP to a dev server that is almost never
|
||||
// TLS-terminated; https would fail the handshake against it.
|
||||
parsed.protocol = 'http:'
|
||||
|
||||
return parsed.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a forward for `rawUrl`, or null when it isn't a loopback address.
|
||||
*
|
||||
* Failure to forward is thrown, not swallowed: the caller decides whether a
|
||||
* dead tunnel is worth surfacing, and a silent null here would be
|
||||
* indistinguishable from "this URL was fine all along".
|
||||
*/
|
||||
export async function openPreviewReach(rawUrl: string, deps: PreviewReachDeps): Promise<null | PreviewReachLease> {
|
||||
const target = loopbackTarget(rawUrl)
|
||||
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
|
||||
const localPort = await deps.pickLocalPort()
|
||||
|
||||
// The connection can die between picking a port and using it.
|
||||
if (!deps.isCurrent()) {
|
||||
return null
|
||||
}
|
||||
|
||||
await deps.forward(localPort, target.port, '127.0.0.1')
|
||||
|
||||
let closed = false
|
||||
let timer: null | ReturnType<typeof setTimeout> = null
|
||||
|
||||
const close = async () => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
await deps.cancel(localPort, target.port)
|
||||
}
|
||||
|
||||
timer = setTimeout(() => void close(), PREVIEW_REACH_LEASE_MS)
|
||||
// A pending lease timer must never hold the app open at quit. Node's timer
|
||||
// has unref; the DOM lib's number type (what tsc picks here) does not.
|
||||
;(timer as unknown as { unref?: () => void }).unref?.()
|
||||
|
||||
return {
|
||||
close,
|
||||
expiresAt: Date.now() + PREVIEW_REACH_LEASE_MS,
|
||||
localPort,
|
||||
remoteHost: target.host,
|
||||
remotePort: target.port,
|
||||
url: rewriteToLocalPort(rawUrl, localPort)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards currently open, keyed by the remote port they reach.
|
||||
*
|
||||
* One lease per remote port, not per URL: navigating around a dev server
|
||||
* (`/`, `/about`, `?q=1`) is the same tunnel, and minting a fresh one per page
|
||||
* would leak a socket per click.
|
||||
*/
|
||||
export class PreviewReachRegistry {
|
||||
private leases = new Map<number, PreviewReachLease>()
|
||||
|
||||
/** Reuse a live lease for this port, else open one. */
|
||||
async resolve(rawUrl: string, deps: PreviewReachDeps): Promise<null | string> {
|
||||
const target = loopbackTarget(rawUrl)
|
||||
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existing = this.leases.get(target.port)
|
||||
|
||||
if (existing && existing.expiresAt > Date.now()) {
|
||||
return rewriteToLocalPort(rawUrl, existing.localPort)
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await existing.close().catch(() => {})
|
||||
this.leases.delete(target.port)
|
||||
}
|
||||
|
||||
const lease = await openPreviewReach(rawUrl, deps)
|
||||
|
||||
if (!lease) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.leases.set(target.port, lease)
|
||||
|
||||
return lease.url
|
||||
}
|
||||
|
||||
/** Drop every forward — the authorizing connection changed or went away. */
|
||||
async closeAll(): Promise<void> {
|
||||
const open = [...this.leases.values()]
|
||||
|
||||
this.leases.clear()
|
||||
await Promise.allSettled(open.map(lease => lease.close()))
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.leases.size
|
||||
}
|
||||
}
|
||||
@@ -17,5 +17,5 @@
|
||||
"outDir": "build/electron-types"
|
||||
},
|
||||
"include": ["electron", "../shared/src/translucency.ts"],
|
||||
"exclude": ["src"]
|
||||
"exclude": ["src", "electron/**/*.e2e.mts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user