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 .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"port": 3000
}
]
}
27 changes: 22 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ on:
- main
workflow_dispatch:
inputs:
target:
description: Platform to build
required: false
default: all
type: choice
options:
- all
- windows
- macos
windows_prerelease_tag:
description: Optional non-v tag for a Windows Dev pre-release
required: false
Expand All @@ -24,6 +33,9 @@ concurrency:
jobs:
macos-apple-silicon:
name: macOS Apple Silicon
if: >-
startsWith(github.ref, 'refs/tags/v') ||
(github.event_name == 'workflow_dispatch' && (inputs.target == 'all' || inputs.target == 'macos'))
runs-on: macos-15
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -154,7 +166,9 @@ jobs:

macos-intel:
name: macOS Intel
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
if: >-
startsWith(github.ref, 'refs/tags/v') ||
(github.event_name == 'workflow_dispatch' && (inputs.target == 'all' || inputs.target == 'macos'))
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -285,7 +299,10 @@ jobs:

windows-x64:
name: Windows x64
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
if: >-
startsWith(github.ref, 'refs/tags/v') ||
github.event_name == 'pull_request' ||
(github.event_name == 'workflow_dispatch' && (inputs.target == 'all' || inputs.target == 'windows' || inputs.target == '' || inputs.windows_prerelease_tag != ''))
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
Expand All @@ -305,10 +322,10 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
run: npm run package:win
- name: Build isolated Windows development package
if: github.event_name == 'workflow_dispatch'
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: npm run package:dev:win
- name: Smoke test packaged Windows Harness
if: github.event_name == 'workflow_dispatch'
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
shell: pwsh
run: |
$userData = Join-Path $env:APPDATA 'dsh-desktop-dev'
Expand Down Expand Up @@ -414,7 +431,7 @@ jobs:
dist/latest.yml
if-no-files-found: error
- uses: actions/upload-artifact@v4
if: github.event_name == 'workflow_dispatch'
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
with:
name: windows-x64-dev
path: |
Expand Down
13 changes: 13 additions & 0 deletions build/harness-node-entry.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { pathToFileURL } from 'node:url'

// On macOS Harness runs inside an Electron utility process (TCC responsibility
// isolation), so `process.execPath` and `argv0` point at the Electron helper
// instead of a Node binary. Plugins re-invoke the dsh CLI through the
// executable running them — dsh-market forwards `process.execArgv` with it —
// and without Node mode that child boots as an Electron app, where the leading
// `--expose-internals` shifts argv and the CLI answers "--profile <name> is
// required" instead of installing. Declaring it here, after this process has
// already parsed the Chromium switches it was launched with, marks only the
// children as Node processes. Bundled-Node hosts (Windows, Linux) skip it.
if (process.versions.electron !== undefined) {
process.env.ELECTRON_RUN_AS_NODE = '1'
}

const [dshEntryPath, ...dshArguments] = process.argv.slice(2)

function report(label, value) {
Expand Down
4 changes: 2 additions & 2 deletions packages/dsh-desktop-market-installer/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ window.__ModuleLoader__.load({
setStatus((current) => ({
...current,
phase: 'installing',
recommendedVersion: current?.recommendedVersion || '1.9.0'
recommendedVersion: current?.recommendedVersion || '1.15.0'
}))
try {
const response = await fetch(INSTALL_PATH, {
Expand Down Expand Up @@ -539,7 +539,7 @@ window.__ModuleLoader__.load({
const busy = phase === 'installing'
const installed = phase === 'installed'
const failed = phase === 'error' || phase === 'incomplete' || Boolean(error)
const version = status?.recommendedVersion || '1.9.0'
const version = status?.recommendedVersion || '1.15.0'

return React.createElement(
'section',
Expand Down
87 changes: 74 additions & 13 deletions packages/dsh-desktop-market-installer/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { chmod, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
import { delimiter, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

export const RECOMMENDED_MARKET_VERSION = '1.9.0'
import { SIDELINE_MARKER } from './pnpm-runner.mjs'
import { removeTree } from './remove-tree.mjs'

export const RECOMMENDED_MARKET_VERSION = '1.15.0'
export const MARKET_PACKAGE = 'dshmarket'
export const MARKET_PROFILE = 'web'
export const STATUS_PATH = '/dsh-desktop/market-installer/status'
Expand All @@ -26,19 +30,40 @@ export function profileDirectory(home = dshHome()) {
return join(home, 'profiles', MARKET_PROFILE)
}

/** Leftovers of an interrupted pnpm run, or of a Windows locked-rename recovery. */
export function isDisposableModuleDirectory(name) {
return name.includes('_tmp_') || name.includes(SIDELINE_MARKER)
}

/**
* Sweep the leftovers of interrupted pnpm runs from a profile's node_modules.
*
* A package's own node_modules is swept too. Once the package being replaced
* is a dependency of a dependency, that is where the leftovers land —
* `cytoscape-fcose/node_modules/cose-base.dsh-old-…` — and a sweep that stops
* at the top level leaves one copy behind per attempt.
*/
export async function cleanStaleTemporaryDirectories(home = dshHome()) {
const directory = profileDirectory(home)
const nodeModulesPath = join(directory, 'node_modules')
try {
const entries = await readdir(nodeModulesPath, { withFileTypes: true })
const sweep = async (nodeModulesPath) => {
let entries
try {
entries = await readdir(nodeModulesPath, { withFileTypes: true })
} catch {
// node_modules directory may not exist yet
return
}
for (const entry of entries) {
if (entry.isDirectory() && entry.name.includes('_tmp_')) {
await rm(join(nodeModulesPath, entry.name), { recursive: true, force: true }).catch(() => undefined)
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
const path = join(nodeModulesPath, entry.name)
if (isDisposableModuleDirectory(entry.name)) {
await removeTree(path).catch(() => undefined)
continue
}
await sweep(entry.name.startsWith('@') ? path : join(path, 'node_modules'))
}
} catch {
// node_modules directory may not exist yet
}
await sweep(join(directory, 'node_modules'))
}

function readObject(text) {
Expand Down Expand Up @@ -128,37 +153,73 @@ export function resolvePnpmEntry(requireFrom = import.meta.url) {
return entry
}

/**
* Put the lock-recovery runner where the shims can invoke it.
* @returns the staged runner path, or undefined when neither the staged copy
* nor the packaged original can be used — the shims then call pnpm directly.
*/
export async function stagePnpmRunner(directory) {
const source = fileURLToPath(new URL('./pnpm-runner.mjs', import.meta.url))
const staged = join(directory, 'pnpm-runner.mjs')
try {
await copyFile(source, staged)
return staged
} catch {
return existsSync(source) ? source : undefined
}
}

export async function ensurePnpmShim(home = dshHome()) {
const directory = join(home, '.desktop-bin')
await mkdir(directory, { recursive: true })
const pnpmEntry = resolvePnpmEntry()
const executable = process.execPath

// pnpm is reached through this shim by every profile package operation —
// DSH Desktop's installer and the community market alike — so the runner it
// points at is where a Windows locked rename gets recovered for both. A
// runner that cannot be staged must not take the shims down with it: pnpm
// still has to be reachable, just without the recovery, and the harness log
// has to say so rather than leaving a stale shim to be mistaken for a fresh
// one.
const runnerPath = await stagePnpmRunner(directory)
const pnpmCommand = runnerPath === undefined ? [pnpmEntry] : [runnerPath, pnpmEntry]
process.stdout.write(
runnerPath === undefined
? 'dsh-desktop: pnpm shim written without the lock-recovery runner\n'
: `dsh-desktop: pnpm shim written via ${runnerPath}\n`
)

// The packaged executable is Electron on macOS, where Harness runs as a
// utility process. Anything invoked through these shims expects Node
// semantics — a leading node flag included — so the shims declare Node mode
// themselves instead of relying on the caller's environment. The real Node
// runtime bundled on the other platforms ignores the variable.
if (process.platform === 'win32') {
const pnpmPath = join(directory, 'pnpm.cmd')
await writeFile(
pnpmPath,
`@chcp 65001 >nul\r\n@echo off\r\n\"${executable}\" \"${pnpmEntry}\" %*\r\n`,
`@chcp 65001 >nul\r\n@echo off\r\n@set ELECTRON_RUN_AS_NODE=1\r\n\"${executable}\" ${pnpmCommand.map((part) => `\"${part}\"`).join(' ')} %*\r\n`,
'utf8'
)
const nodePath = join(directory, 'node.cmd')
await writeFile(
nodePath,
`@chcp 65001 >nul\r\n@echo off\r\n\"${executable}\" %*\r\n`,
`@chcp 65001 >nul\r\n@echo off\r\n@set ELECTRON_RUN_AS_NODE=1\r\n\"${executable}\" %*\r\n`,
'utf8'
)
} else {
const pnpmPath = join(directory, 'pnpm')
await writeFile(
pnpmPath,
`#!/bin/sh\nexec ${shellQuote(executable)} ${shellQuote(pnpmEntry)} \"$@\"\n`,
`#!/bin/sh\nexport ELECTRON_RUN_AS_NODE=1\nexec ${shellQuote(executable)} ${pnpmCommand.map(shellQuote).join(' ')} \"$@\"\n`,
{ encoding: 'utf8', mode: 0o755 }
)
await chmod(pnpmPath, 0o755)
const nodePath = join(directory, 'node')
await writeFile(
nodePath,
`#!/bin/sh\nexec ${shellQuote(executable)} \"$@\"\n`,
`#!/bin/sh\nexport ELECTRON_RUN_AS_NODE=1\nexec ${shellQuote(executable)} \"$@\"\n`,
{ encoding: 'utf8', mode: 0o755 }
)
await chmod(nodePath, 0o755)
Expand Down
Loading
Loading