mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-10 02:31:27 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faae2b36ac | ||
|
|
565ada6b86 | ||
|
|
876faa7ba2 | ||
|
|
11594437b8 | ||
|
|
bfe99c06e2 | ||
|
|
f50d3099ce | ||
|
|
00573f448f | ||
|
|
573bac6b7a | ||
|
|
5e7304a750 | ||
|
|
5d86688130 |
No files matched your search
@@ -103,7 +103,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: git diff --exit-code src/bindings.ts
|
||||
|
||||
# The remote development server ships as a static Linux binary (release.yml):
|
||||
# The remote development server ships as a static Linux binary (release-build.yml):
|
||||
# keep that build working.
|
||||
server:
|
||||
name: Remote server (linux musl)
|
||||
|
||||
@@ -47,6 +47,7 @@ env:
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
INTELEPHENSE: 1.18.5
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
@@ -54,8 +55,9 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [typescript, python, rust, go, java, kotlin]
|
||||
runs-on: ubuntu-22.04
|
||||
language: [typescript, python, rust, go, java, kotlin, php, c, cpp]
|
||||
# C/C++ debug with GDB, whose DAP interpreter needs 14+: 22.04 ships 12.1, 24.04 ships 15.
|
||||
runs-on: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 'ubuntu-24.04' || 'ubuntu-22.04' }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -74,6 +76,12 @@ jobs:
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
# The smoke builds the c-app fixture and debugs it with GDB's own DAP interpreter.
|
||||
- if: matrix.language == 'c' || matrix.language == 'cpp'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gdb
|
||||
|
||||
- if: matrix.language == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
@@ -108,6 +116,13 @@ jobs:
|
||||
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
|
||||
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke.
|
||||
- if: matrix.language == 'php'
|
||||
uses: shivammathur/setup-php@v2
|
||||
with: { php-version: "8.5", coverage: xdebug, tools: composer }
|
||||
- if: matrix.language == 'php'
|
||||
run: npm install --global "intelephense@$INTELEPHENSE"
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
name: Nightly
|
||||
|
||||
# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own
|
||||
# version, so remote development finds the server of that version. The `nightly` release holds
|
||||
# the manifest the nightly app updates from; its tag marks the commit last built, and a run with
|
||||
# nothing newer stops there. Only the latest builds are kept.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2,14 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: "Build even if nothing is newer than the last nightly"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
if: github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.commit.outputs.sha }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
steps:
|
||||
- id: commit
|
||||
env:
|
||||
FORCE: ${{ inputs.force }}
|
||||
run: |
|
||||
sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \
|
||||
--json headSha --jq '.[0].headSha // ""')"
|
||||
if [ -z "$sha" ]; then
|
||||
echo "::notice::no main commit has passed CI"
|
||||
exit 0
|
||||
fi
|
||||
# No `nightly` tag yet (the first run): compare fails and the build goes on.
|
||||
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
|
||||
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
|
||||
echo "::notice::$sha is not newer than the last nightly"
|
||||
exit 0
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.commit.outputs.sha != ''
|
||||
with: { ref: "${{ steps.commit.outputs.sha }}" }
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.commit.outputs.sha != ''
|
||||
with: { node-version: 22 }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.commit.outputs.sha != ''
|
||||
- id: release
|
||||
if: steps.commit.outputs.sha != ''
|
||||
env:
|
||||
SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")"
|
||||
tag="v$version"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -am "chore: $tag"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \
|
||||
-F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
binaries:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.tag != ''
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
release_id: ${{ needs.prepare.outputs.release_id }}
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [prepare, binaries]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
SHA: ${{ needs.prepare.outputs.sha }}
|
||||
KEEP: 14
|
||||
steps:
|
||||
- name: Publish the build
|
||||
run: gh release edit "$TAG" --tag "$TAG" --draft=false
|
||||
- name: Point the updater at it
|
||||
run: |
|
||||
gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP"
|
||||
gh release view nightly >/dev/null 2>&1 ||
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \
|
||||
--notes "The update manifest of the nightly builds, and the commit last built."
|
||||
gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber
|
||||
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent
|
||||
# Failed builds' drafts go too, once they are this old.
|
||||
- name: Delete old builds
|
||||
run: |
|
||||
gh release list --limit 200 --json tagName,createdAt --jq \
|
||||
'[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' |
|
||||
while read -r tag; do
|
||||
gh release delete "$tag" --cleanup-tag --yes
|
||||
done
|
||||
@@ -0,0 +1,262 @@
|
||||
name: Release build
|
||||
|
||||
# Builds a tag's desktop apps, remote servers and updater manifest into its draft release.
|
||||
# Stable releases (release.yml) and nightly builds (nightly.yml) both run it.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag: { type: string, required: true }
|
||||
release_id: { type: string, required: true }
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
TAG: ${{ inputs.tag }}
|
||||
|
||||
jobs:
|
||||
plugin-keys:
|
||||
permissions: {}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
keys: ${{ steps.keys.outputs.keys }}
|
||||
steps:
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: plugin-keys
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.plugin-keys.outputs.keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Signing is optional: only secrets that are set get exported, so forks and
|
||||
# unconfigured repos produce unsigned builds instead of failing.
|
||||
- name: Signing env
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
|
||||
APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }}
|
||||
APPLE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_PASSWORD || '' }}
|
||||
APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
|
||||
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
|
||||
done
|
||||
true
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ inputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
if: runner.os == 'macOS' && env.APPLE_ID != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
|
||||
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" --wait
|
||||
xcrun stapler staple "$dmg"
|
||||
# Release assets have no spaces: GitHub stored the first upload with dots.
|
||||
asset="$RUNNER_TEMP/$(basename "$dmg" | tr ' ' .)"
|
||||
cp "$dmg" "$asset"
|
||||
gh release upload "$TAG" "$asset" --clobber
|
||||
done
|
||||
# Portable Windows builds (#1857): on Windows Tauri embeds the
|
||||
# frontend in the exe and WebView2 is a system component, so zipping
|
||||
# the raw exe gives an unpack-and-run alternative to the installers.
|
||||
- name: Portable zip
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$target = "${{ matrix.target }}"
|
||||
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
|
||||
$version = $env:TAG -replace '^v', ''
|
||||
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
|
||||
$dir = "target/$target/release"
|
||||
$files = @("$dir/openidea.exe")
|
||||
if (Test-Path "$dir/WebView2Loader.dll") {
|
||||
$files += "$dir/WebView2Loader.dll"
|
||||
}
|
||||
Compress-Archive -Path $files -DestinationPath $asset -Force
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
# Linux builds are static (musl), so they run whatever the host's glibc.
|
||||
server:
|
||||
permissions:
|
||||
contents: write
|
||||
name: Server (${{ matrix.os }}-${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
|
||||
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
|
||||
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- name: Build
|
||||
run: |
|
||||
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
|
||||
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
|
||||
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
- name: Upload
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
|
||||
run: |
|
||||
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ inputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
// Nightly versions don't fit MSI or RPM, so those builds offer NSIS on Windows.
|
||||
const nightly = tag.includes("-nightly.");
|
||||
const windows = (arch, msi, nsis) => nightly
|
||||
? [[nsis, [`windows-${arch}`, `windows-${arch}-nsis`]]]
|
||||
: [[msi, [`windows-${arch}`, `windows-${arch}-msi`]], [nsis, [`windows-${arch}-nsis`]]];
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
...(nightly ? [] : [[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]]]),
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
...(nightly ? [] : [[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]]]),
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
|
||||
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
+24
-229
@@ -1,7 +1,7 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
tags: ["v*", "!v*-nightly.*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.event.inputs.tag || github.ref_name }}
|
||||
@@ -23,10 +23,12 @@ env:
|
||||
jobs:
|
||||
# Creates (or reuses) one draft release that every platform build uploads into.
|
||||
create-release:
|
||||
if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-nightly.') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
@@ -47,20 +49,6 @@ jobs:
|
||||
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
|
||||
exit 1
|
||||
fi
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: plugin-keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
@@ -74,161 +62,26 @@ jobs:
|
||||
core.setOutput("id", existing.id);
|
||||
return;
|
||||
}
|
||||
// Nightly builds are prereleases: the notes span stable release to stable release.
|
||||
const previous = releases.find((r) => !r.draft && !r.prerelease);
|
||||
const { data: notes } = await github.rest.repos.generateReleaseNotes({
|
||||
owner, repo, tag_name: tag, previous_tag_name: previous?.tag_name,
|
||||
});
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`,
|
||||
draft: true, prerelease: tag.includes("-"), generate_release_notes: true,
|
||||
draft: true, prerelease: tag.includes("-"), body: notes.body,
|
||||
});
|
||||
core.setOutput("id", data.id);
|
||||
|
||||
build:
|
||||
binaries:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: create-release
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Signing is optional: only secrets that are set get exported, so forks and
|
||||
# unconfigured repos produce unsigned builds instead of failing.
|
||||
- name: Signing env
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
|
||||
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
|
||||
done
|
||||
true
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
if: runner.os == 'macOS' && env.APPLE_ID != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
|
||||
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" --wait
|
||||
xcrun stapler staple "$dmg"
|
||||
gh release upload "$TAG" "$dmg" --clobber
|
||||
done
|
||||
# Portable Windows builds (#1857): on Windows Tauri embeds the
|
||||
# frontend in the exe and WebView2 is a system component, so zipping
|
||||
# the raw exe gives an unpack-and-run alternative to the installers.
|
||||
- name: Portable zip
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$target = "${{ matrix.target }}"
|
||||
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
|
||||
$version = $env:TAG -replace '^v', ''
|
||||
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
|
||||
$dir = "target/$target/release"
|
||||
$files = @("$dir/openidea.exe")
|
||||
if (Test-Path "$dir/WebView2Loader.dll") {
|
||||
$files += "$dir/WebView2Loader.dll"
|
||||
}
|
||||
Compress-Archive -Path $files -DestinationPath $asset -Force
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
# Linux builds are static (musl), so they run whatever the host's glibc.
|
||||
server:
|
||||
needs: create-release
|
||||
name: Server (${{ matrix.os }}-${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
|
||||
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
|
||||
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- name: Build
|
||||
run: |
|
||||
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
|
||||
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
|
||||
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
- name: Upload
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
|
||||
run: |
|
||||
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
tag: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}
|
||||
secrets: inherit
|
||||
|
||||
# The official plugins, signed, and the marketplace index released apps read
|
||||
# (releases/latest/download/plugins-index.json). They are not in the
|
||||
@@ -240,6 +93,8 @@ jobs:
|
||||
# openidea-plugin tool for Linux x64, which that workflow and the registry's
|
||||
# checks run.
|
||||
plugins:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: create-release
|
||||
name: Official plugins
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -303,70 +158,10 @@ jobs:
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
|
||||
"$out/openidea-plugin-linux-x86_64.gz" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
|
||||
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
|
||||
publish:
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [create-release, binaries, plugins]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
|
||||
@@ -76,6 +76,4 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
@@ -103,6 +103,14 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
}
|
||||
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
|
||||
// (`prepare_gdb` refuses older ones with the required version in the message).
|
||||
"gdb" => cfg(
|
||||
"gdb",
|
||||
&["-i", "dap"],
|
||||
"stdio",
|
||||
"Install GDB 14 or newer (apt install gdb, dnf install gdb, brew install gdb)",
|
||||
),
|
||||
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
|
||||
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
|
||||
"java-debug" => cfg(
|
||||
@@ -129,6 +137,7 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
|
||||
"delve" => "go",
|
||||
"netcoredbg" => "coreclr",
|
||||
"lldb-dap" => "lldb-dap",
|
||||
"gdb" => "gdb",
|
||||
"java-debug" => "java",
|
||||
_ => return None,
|
||||
})
|
||||
@@ -335,6 +344,89 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
|
||||
)))
|
||||
}
|
||||
|
||||
/// GDB's DAP interpreter (`-i dap`) shipped in GDB 14; older ones exit with
|
||||
/// "Interpreter `dap' unrecognized".
|
||||
const GDB_MIN_MAJOR: u32 = 14;
|
||||
|
||||
/// `(major, minor)` of the first version number in the output of `gdb --version`
|
||||
/// (`GNU gdb (GDB) 14.2`, `GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1`, `GNU gdb
|
||||
/// (GDB) 15.0.50.20240403-0ubuntu1`). `None` when no standalone version number is there.
|
||||
pub fn parse_gdb_version(output: &str) -> Option<(u32, u32)> {
|
||||
let line = output.lines().next()?;
|
||||
// Distribution names can contain versions, e.g. `(GDB; SUSE Linux Enterprise 15) 14.2`.
|
||||
let line = line.rsplit_once(')').map_or(line, |(_, version)| version);
|
||||
let b = line.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if !b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
let major: u32 = line[start..i].parse().ok()?;
|
||||
let mut minor = 0;
|
||||
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
|
||||
i += 1;
|
||||
let ms = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
minor = line[ms..i].parse().unwrap_or(0);
|
||||
}
|
||||
// A version stands alone: `mingw32-gdb13.exe`'s embedded digits are part of a word.
|
||||
if start > 0 && (b[start - 1].is_ascii_alphabetic() || b[start - 1] == b'_') {
|
||||
continue;
|
||||
}
|
||||
return Some((major, minor));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Probe the already resolved and verified executable with its launch environment.
|
||||
async fn gdb_version(binary: &Path, spec: &AdapterSpec) -> IdeResult<Option<(u32, u32)>> {
|
||||
let mut probe = Command::new(binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
probe.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(probe.as_std_mut());
|
||||
let probe = probe
|
||||
.arg("--version")
|
||||
.current_dir(&spec.cwd)
|
||||
.envs(spec.env.iter().cloned())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
let Ok(Ok(out)) = tokio::time::timeout(Duration::from_secs(15), probe).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(parse_gdb_version(&String::from_utf8_lossy(&out.stdout)))
|
||||
}
|
||||
|
||||
/// Refuse old GDB versions and report whether launch must follow configurationDone.
|
||||
/// GDB 15 starts the inferior in launch; 14 and 16+ start it at configurationDone.
|
||||
async fn prepare_gdb(spec: &AdapterSpec, binary: &Path) -> IdeResult<bool> {
|
||||
let Some((major, minor)) = gdb_version(binary, spec).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if major >= GDB_MIN_MAJOR {
|
||||
return Ok(major == 15);
|
||||
}
|
||||
let mut msg = format!(
|
||||
"debugging with GDB needs GDB {GDB_MIN_MAJOR} or newer (found {major}.{minor}): \
|
||||
this one has no DAP interpreter (`gdb -i dap`)"
|
||||
);
|
||||
if let Some(hint) = &spec.install_hint {
|
||||
msg.push_str(". ");
|
||||
msg.push_str(hint);
|
||||
}
|
||||
Err(IdeError::unavailable(msg))
|
||||
}
|
||||
|
||||
/// An adapter that is a plugin of another program (java-debug, a jdtls bundle) rather
|
||||
/// than a program: it has no process of its own and is reached through `debugServer`.
|
||||
pub fn is_plugin_bundle(command: &str) -> bool {
|
||||
@@ -368,6 +460,8 @@ pub type BoxWriter = Box<dyn AsyncWrite + Unpin + Send>;
|
||||
|
||||
/// A connected adapter: byte streams plus the process (if we spawned one).
|
||||
pub struct AdapterIo {
|
||||
/// GDB 15 starts running inside `launch`; configure breakpoints before sending it.
|
||||
pub launch_after_configuration: bool,
|
||||
pub reader: BoxReader,
|
||||
pub writer: BoxWriter,
|
||||
pub child: Option<Child>,
|
||||
@@ -419,6 +513,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
let stream = connect_with_retries(host, port, None, timeout).await?;
|
||||
let (r, w) = stream.into_split();
|
||||
return Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -442,6 +537,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let (r, w) = stream.into_split();
|
||||
return Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -470,9 +566,15 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.ok_or_else(|| missing(&spec))?
|
||||
}
|
||||
};
|
||||
let launch_after_configuration =
|
||||
if spec.adapter == "gdb" && spec.target.is_none() { prepare_gdb(&spec, &binary).await? } else { false };
|
||||
let base_args = substitute_root(&spec.args, &spec.cwd);
|
||||
match spec.transport {
|
||||
Transport::Stdio => spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await,
|
||||
Transport::Stdio => {
|
||||
let mut io = spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await?;
|
||||
io.launch_after_configuration = launch_after_configuration;
|
||||
Ok(io)
|
||||
}
|
||||
Transport::Tcp => {
|
||||
// An embedded adapter started without `${port}` listens on its usual
|
||||
// address; every other TCP adapter gets a port chosen here so it can be
|
||||
@@ -489,7 +591,10 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
};
|
||||
let args = substitute_port(&base_args, port);
|
||||
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
|
||||
Ok(io) => return Ok(io),
|
||||
Ok(mut io) => {
|
||||
io.launch_after_configuration = launch_after_configuration;
|
||||
return Ok(io);
|
||||
}
|
||||
Err(e) => {
|
||||
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
|
||||
tracing::warn!(
|
||||
@@ -552,6 +657,7 @@ async fn spawn_adapter(
|
||||
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(stdout),
|
||||
writer: Box::new(stdin),
|
||||
child: Some(child),
|
||||
@@ -583,6 +689,7 @@ async fn spawn_adapter(
|
||||
};
|
||||
let (r, w) = stream.into_split();
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: Some(child),
|
||||
@@ -740,15 +847,102 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn builtins_cover_common_adapters() {
|
||||
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug"] {
|
||||
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug", "gdb"] {
|
||||
let cfg = builtin(name).unwrap();
|
||||
assert!(cfg.install_hint.is_some());
|
||||
}
|
||||
let delve = resolve(&builtin("delve").unwrap(), PathBuf::from("/"));
|
||||
assert_eq!(delve.transport, Transport::Tcp);
|
||||
// GDB's own DAP interpreter over stdio, with the required version in its hint.
|
||||
let gdb = builtin("gdb").unwrap();
|
||||
assert_eq!(gdb.command, "gdb");
|
||||
assert_eq!(gdb.args, ["-i", "dap"]);
|
||||
assert_eq!(gdb.transport, "stdio");
|
||||
assert!(gdb.install_hint.as_deref().is_some_and(|h| h.contains("14")), "{gdb:?}");
|
||||
assert_eq!(default_type("gdb"), Some("gdb"));
|
||||
assert!(builtin("nope").is_none());
|
||||
}
|
||||
|
||||
/// The first version number of `gdb --version`'s first line, however the distro decorates it.
|
||||
#[test]
|
||||
fn parses_gdb_versions() {
|
||||
for (out, want) in [
|
||||
("GNU gdb (GDB) 14.2\nCopyright (C) 2023 Free Software Foundation", Some((14, 2))),
|
||||
("GNU gdb (GDB) 15.0.50.20240403-0ubuntu1\n", Some((15, 0))),
|
||||
("GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1", Some((12, 1))),
|
||||
("GNU gdb (Debian 13.1-3) 13.1", Some((13, 1))),
|
||||
("GNU gdb (GDB) Fedora Linux 16.3-1.fc41", Some((16, 3))),
|
||||
("GNU gdb (GDB; SUSE Linux Enterprise 15) 14.2", Some((14, 2))),
|
||||
("GNU gdb (GDB; SUSE Linux Enterprise 15) 11.1", Some((11, 1))),
|
||||
("mingw32-gdb13.exe (GDB) 13.2", Some((13, 2))),
|
||||
("GNU gdb 14", Some((14, 0))),
|
||||
("no version here", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(parse_gdb_version(out), want, "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gdb_probe_never_executes_a_changed_pinned_program() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
|
||||
std::fs::write(&fake, "approved").unwrap();
|
||||
let pin = ProgramPin::approve(&fake).unwrap();
|
||||
#[cfg(windows)]
|
||||
std::fs::write(&fake, "@echo off\r\necho ran>probe-ran\r\necho GNU gdb (GDB) 15.1\r\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&fake, "#!/bin/sh\ntouch probe-ran\necho 'GNU gdb (GDB) 15.1'\n").unwrap();
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
|
||||
spec.command = fake.to_string_lossy().into_owned();
|
||||
spec.pinned = Some(pin);
|
||||
let error = ProcessLauncher.launch(spec, None).await.err().expect("changed program is refused");
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::PermissionDenied);
|
||||
assert!(!dir.path().join("probe-ran").exists(), "the version probe executed an unapproved program");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gdb_probe_uses_the_verified_program_and_launch_environment_once() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
|
||||
#[cfg(windows)]
|
||||
std::fs::write(&fake, "@echo off\r\nif \"%~1\"==\"--version\" (\r\n echo probe>>probes\r\n echo GNU gdb ^(GDB^) %GDB_TEST_VERSION%\r\n) else (\r\n echo adapter\r\n)\r\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&fake, "#!/bin/sh\nif [ \"$1\" = --version ]; then\n echo probe >> probes\n echo \"GNU gdb (GDB) $GDB_TEST_VERSION\"\nelse\n echo adapter\nfi\n").unwrap();
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let pin = ProgramPin::approve(&fake).unwrap();
|
||||
for (version, deferred) in [("12.1", false), ("14.2", false), ("15.1", true), ("16.3", false)] {
|
||||
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
|
||||
// Only the approved path is executable; the symbolic command must never be probed.
|
||||
spec.command = "definitely-not-a-real-binary-openidea".into();
|
||||
spec.pinned = Some(pin.clone());
|
||||
spec.env.push(("GDB_TEST_VERSION".into(), version.into()));
|
||||
let result = ProcessLauncher.launch(spec, None).await;
|
||||
if version == "12.1" {
|
||||
let error = result.err().expect("GDB 12 has no DAP interpreter");
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(error.message.contains("GDB 14 or newer"), "{}", error.message);
|
||||
assert!(error.message.contains("12.1"), "{}", error.message);
|
||||
} else {
|
||||
let mut io = result.unwrap();
|
||||
assert_eq!(io.launch_after_configuration, deferred, "{version}");
|
||||
let mut output = String::new();
|
||||
io.reader.read_to_string(&mut output).await.unwrap();
|
||||
assert_eq!(output.trim(), "adapter");
|
||||
io.child.as_mut().unwrap().wait().await.unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(dir.path().join("probes")).unwrap().lines().count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_port_is_nonzero() {
|
||||
assert_ne!(free_port().unwrap(), 0);
|
||||
|
||||
+50
-12
@@ -746,6 +746,10 @@ impl DebugManager {
|
||||
{
|
||||
spec.env.push(("JAVA_HOME".into(), home));
|
||||
}
|
||||
// Delve looks up Go before applying the debuggee's launch environment.
|
||||
if config.adapter == "delve" {
|
||||
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
|
||||
}
|
||||
if config.adapter == "rdbg" {
|
||||
rdbg_command_line(&mut spec, &config, &cwd)?;
|
||||
}
|
||||
@@ -778,6 +782,18 @@ impl DebugManager {
|
||||
if let Some(server) = &debug_server {
|
||||
spec.server_host = server.host.clone();
|
||||
}
|
||||
if config.adapter == "gdb" && config.request == "launch" && debug_server.is_none() {
|
||||
// GDB's DAP env replaces the entire inferior environment. Ordinary run overrides
|
||||
// instead belong on the adapter process, which the inferior inherits. Explicit
|
||||
// launch JSON keeps GDB's replacement semantics.
|
||||
let explicit_env = config.extra_json.as_deref().is_some_and(|extra| {
|
||||
serde_json::from_str::<Value>(extra).is_ok_and(|extra| extra.get("env").is_some())
|
||||
});
|
||||
if !explicit_env {
|
||||
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
arguments.as_object_mut().unwrap().remove("env");
|
||||
}
|
||||
}
|
||||
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
|
||||
let cargo_args: Vec<String> = cargo
|
||||
.get("args")
|
||||
@@ -893,6 +909,7 @@ impl DebugManager {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
let launch_after_configuration = io.launch_after_configuration && request == "launch";
|
||||
let stderr = io.stderr.clone();
|
||||
let (conn, incoming) = {
|
||||
let stderr = stderr.clone();
|
||||
@@ -935,7 +952,7 @@ impl DebugManager {
|
||||
let configured = tokio::select! {
|
||||
biased;
|
||||
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
result = self.configure(&session, arguments) => result,
|
||||
result = self.configure(&session, arguments, launch_after_configuration) => result,
|
||||
};
|
||||
match configured {
|
||||
Ok(()) => Ok(id),
|
||||
@@ -976,7 +993,7 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
|
||||
async fn configure(&self, s: &Arc<Session>, mut arguments: Value, launch_in_config: bool) -> IdeResult<()> {
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
let project = arguments
|
||||
.get("projectRoot")
|
||||
@@ -1042,16 +1059,28 @@ impl DebugManager {
|
||||
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
|
||||
// failure; Stop cancels configuration and a closed connection fails the pending reply.
|
||||
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
|
||||
let launch = s.conn.send_request(request, arguments)?;
|
||||
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
|
||||
// GDB 15 is the exception: its `launch` starts the program itself (14 runs at
|
||||
// `configurationDone`, 16+ defers the launch there), so its launch waits below.
|
||||
let mut launch = if launch_in_config {
|
||||
None
|
||||
} else {
|
||||
let reply = s.conn.send_request(request, arguments.clone())?;
|
||||
Some(Box::pin(async move { reply.await.unwrap_or(Err(DapError::Closed)) }))
|
||||
};
|
||||
let mut initialized = s.initialized.subscribe();
|
||||
let mut launch_result = None;
|
||||
let got_initialized = tokio::select! {
|
||||
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
|
||||
r = &mut launch => {
|
||||
r?;
|
||||
launch_result = Some(());
|
||||
false
|
||||
let got_initialized = match launch.as_mut() {
|
||||
Some(launch) => tokio::select! {
|
||||
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
|
||||
r = launch => {
|
||||
r?;
|
||||
launch_result = Some(());
|
||||
false
|
||||
}
|
||||
},
|
||||
None => {
|
||||
initialized.wait_for(|v| *v).await.map_err(|_| DapError::Closed)?;
|
||||
true
|
||||
}
|
||||
};
|
||||
if !got_initialized {
|
||||
@@ -1109,8 +1138,17 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
s.configured.store(true, Ordering::Release);
|
||||
if launch_result.is_none() {
|
||||
launch.await?;
|
||||
match launch.as_mut() {
|
||||
Some(launch) => {
|
||||
if launch_result.is_none() {
|
||||
launch.await?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// GDB 15: the breakpoints are in, so its `run` cannot miss them.
|
||||
let reply = s.conn.send_request(request, arguments)?;
|
||||
reply.await.unwrap_or(Err(DapError::Closed))?;
|
||||
}
|
||||
}
|
||||
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
|
||||
relay.release();
|
||||
|
||||
+135
-18
@@ -126,6 +126,10 @@ struct Script {
|
||||
pending_startup: Option<bool>,
|
||||
startup_error: Option<&'static str>,
|
||||
close_during_startup: bool,
|
||||
/// GDB 15 semantics: `initialized` right after `initialize`, the program starts at
|
||||
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
|
||||
gdb15_starts_at_launch: bool,
|
||||
launch_after_configuration: bool,
|
||||
}
|
||||
|
||||
impl Default for Script {
|
||||
@@ -145,6 +149,8 @@ impl Default for Script {
|
||||
pending_startup: None,
|
||||
startup_error: None,
|
||||
close_during_startup: false,
|
||||
gdb15_starts_at_launch: false,
|
||||
launch_after_configuration: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,10 +165,12 @@ impl AdapterLauncher for FakeLauncher {
|
||||
let (client, server) = tokio::io::duplex(1 << 16);
|
||||
let log = self.log.clone();
|
||||
let script = self.script.clone();
|
||||
let launch_after_configuration = script.launch_after_configuration;
|
||||
tokio::spawn(fake_adapter(server, log, script));
|
||||
let (r, w) = tokio::io::split(client);
|
||||
Box::pin(async move {
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -180,6 +188,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
let mut seq = 1000;
|
||||
let mut pending_launch: Option<i64> = None;
|
||||
let mut held: Vec<Value> = Vec::new();
|
||||
let mut running = false;
|
||||
macro_rules! send {
|
||||
($v:expr) => {{
|
||||
let mut v: Value = $v;
|
||||
@@ -200,24 +209,37 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
continue;
|
||||
}
|
||||
match msg["command"].as_str().unwrap_or_default() {
|
||||
"initialize" => ok!(
|
||||
msg,
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsCompletionsRequest": true,
|
||||
"supportsTerminateRequest": script.supports_terminate,
|
||||
"supportsSetVariable": true,
|
||||
"supportsFunctionBreakpoints": true,
|
||||
"supportsDataBreakpoints": true,
|
||||
"supportsStepInTargetsRequest": true,
|
||||
"supportsRestartFrame": true,
|
||||
"exceptionBreakpointFilters": [
|
||||
{"filter": "raised", "label": "Raised"},
|
||||
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
|
||||
],
|
||||
})
|
||||
),
|
||||
"initialize" => {
|
||||
ok!(
|
||||
msg,
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsCompletionsRequest": true,
|
||||
"supportsTerminateRequest": script.supports_terminate,
|
||||
"supportsSetVariable": true,
|
||||
"supportsFunctionBreakpoints": true,
|
||||
"supportsDataBreakpoints": true,
|
||||
"supportsStepInTargetsRequest": true,
|
||||
"supportsRestartFrame": true,
|
||||
"exceptionBreakpointFilters": [
|
||||
{"filter": "raised", "label": "Raised"},
|
||||
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
|
||||
],
|
||||
})
|
||||
);
|
||||
if script.gdb15_starts_at_launch {
|
||||
// GDB 15 announces itself before it sees `launch` (which a client that
|
||||
// defers it sends late).
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
}
|
||||
}
|
||||
"launch" | "attach" => {
|
||||
if script.gdb15_starts_at_launch {
|
||||
// GDB 15 starts the program here and answers `launch` right away.
|
||||
running = true;
|
||||
ok!(msg, Value::Null);
|
||||
continue;
|
||||
}
|
||||
if script.close_during_startup {
|
||||
return;
|
||||
}
|
||||
@@ -289,6 +311,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
|
||||
}
|
||||
"configurationDone" => {
|
||||
if script.gdb15_starts_at_launch && running {
|
||||
// GDB 15 answers `configurationDone` with `notStopped` while the program
|
||||
// it started at `launch` is still running.
|
||||
send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": "configurationDone", "success": false, "message": "notStopped"})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !script.ignore_configuration_done {
|
||||
ok!(msg, Value::Null);
|
||||
}
|
||||
@@ -510,6 +540,60 @@ async fn stop_cancels_pending_launch_and_attach() {
|
||||
}
|
||||
}
|
||||
|
||||
/// GDB 15 must receive breakpoints and configurationDone before launch.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn gdb_15_launch_waits_for_the_configuration() {
|
||||
for deferred in [false, true] {
|
||||
let h =
|
||||
harness(Script { gdb15_starts_at_launch: true, launch_after_configuration: deferred, ..Script::default() });
|
||||
let result = h.start(LaunchConfig { adapter: "gdb".into(), ..config() }).await;
|
||||
if deferred {
|
||||
let id = result.expect("GDB 15 defers its launch past the configuration");
|
||||
let commands = h.commands();
|
||||
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
|
||||
assert!(pos("launch") > pos("configurationDone"), "{commands:?}");
|
||||
h.manager.stop(id).await.unwrap();
|
||||
} else {
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.message.contains("configurationDone failed: notStopped"), "{}", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn gdb_attach_keeps_the_normal_configuration_order() {
|
||||
let h = harness(Script { launch_after_configuration: true, ..Script::default() });
|
||||
let id = h.start(LaunchConfig { adapter: "gdb".into(), request: "attach".into(), ..config() }).await.unwrap();
|
||||
let commands = h.commands();
|
||||
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
|
||||
assert!(pos("attach") < pos("configurationDone"), "{commands:?}");
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn gdb_run_environment_overrides_preserve_inheritance() {
|
||||
for explicit in [None, Some(r#"{"env":{"ONLY":"explicit"}}"#)] {
|
||||
let h = harness(Script::default());
|
||||
let id = h
|
||||
.start(LaunchConfig { adapter: "gdb".into(), extra_json: explicit.map(str::to_owned), ..config() })
|
||||
.await
|
||||
.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
{
|
||||
let log = h.log.lock();
|
||||
let args = &log.iter().find(|m| m["command"] == "launch").unwrap()["arguments"];
|
||||
if explicit.is_some() {
|
||||
assert_eq!(args["env"], json!({"ONLY": "explicit"}));
|
||||
assert!(session.spec.env.is_empty());
|
||||
} else {
|
||||
assert!(args.get("env").is_none(), "GDB must inherit its process environment");
|
||||
assert!(session.spec.env.contains(&("A".into(), "1".into())));
|
||||
}
|
||||
}
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn startup_still_reports_adapter_failure_and_connection_closure() {
|
||||
for request in ["launch", "attach"] {
|
||||
@@ -517,7 +601,6 @@ async fn startup_still_reports_adapter_failure_and_connection_closure() {
|
||||
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
|
||||
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
|
||||
let h = harness(Script { close_during_startup: true, ..Script::default() });
|
||||
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
|
||||
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
|
||||
@@ -1040,6 +1123,39 @@ async fn connects_to_running_debug_server_over_tcp() {
|
||||
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delve_adapter_inherits_the_selected_go_environment() {
|
||||
struct CaptureLauncher {
|
||||
inner: FakeLauncher,
|
||||
env: Arc<Mutex<Vec<(String, String)>>>,
|
||||
}
|
||||
impl AdapterLauncher for CaptureLauncher {
|
||||
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
|
||||
*self.env.lock() = spec.env.clone();
|
||||
self.inner.launch(spec, reuse)
|
||||
}
|
||||
}
|
||||
let env = Arc::new(Mutex::new(Vec::new()));
|
||||
let launcher =
|
||||
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
|
||||
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
|
||||
let (owner, _) = recording_owner("main");
|
||||
let config = LaunchConfig {
|
||||
name: "Go".into(),
|
||||
adapter: "delve".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("/project".into()),
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
|
||||
extra_json: Some(json!({"mode":"debug"}).to_string()),
|
||||
};
|
||||
let id = manager.start(config, owner).await.unwrap();
|
||||
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
|
||||
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
|
||||
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
|
||||
#[tokio::test]
|
||||
@@ -1929,6 +2045,7 @@ async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
|
||||
});
|
||||
let (reader, writer) = tokio::io::split(client);
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(reader),
|
||||
writer: Box::new(writer),
|
||||
child: Some(child),
|
||||
|
||||
@@ -151,6 +151,16 @@ fn cases(dir: &Path) -> Vec<Case> {
|
||||
field: None,
|
||||
config: launch("lldb-dap", &c.join("main"), &c, serde_json::json!({"env": ["OPENIDEA_FIXTURE=1"]})),
|
||||
},
|
||||
// GDB's own DAP interpreter needs GDB 14+; older ones are skipped by `installed`.
|
||||
Case {
|
||||
adapter: "gdb",
|
||||
source: c.join("main.c"),
|
||||
loop_line: 13,
|
||||
after_loop: 15,
|
||||
function: "parse",
|
||||
field: None,
|
||||
config: launch("gdb", &c.join("main"), &c, serde_json::json!({})),
|
||||
},
|
||||
]);
|
||||
out
|
||||
}
|
||||
@@ -230,6 +240,16 @@ fn installed(adapter: &str) -> bool {
|
||||
"js-debug" => "js-debug-adapter",
|
||||
"delve" => "dlv",
|
||||
"java-debug" => return true,
|
||||
// Only a GDB with the DAP interpreter (14+) counts as installed.
|
||||
"gdb" => {
|
||||
return std::process::Command::new("gdb").arg("--version").output().is_ok_and(|out| {
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(adapter::parse_gdb_version)
|
||||
.is_some_and(|(major, _)| major >= 14)
|
||||
});
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
adapter::resolve_binary(cmd, Path::new("/")).is_some()
|
||||
|
||||
@@ -38,6 +38,10 @@ pub enum Capability {
|
||||
Clipboard,
|
||||
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
|
||||
Net(String),
|
||||
/// Ask the user when the first request goes to an HTTPS (or loopback) host
|
||||
/// the manifest does not list; each answer applies to one host. Plain
|
||||
/// HTTP elsewhere always needs `net.http:<host>`.
|
||||
NetAsk,
|
||||
/// Unencrypted HTTP to one host.
|
||||
NetHttp(String),
|
||||
/// A local socket or named pipe on the project machine.
|
||||
@@ -71,6 +75,7 @@ impl Capability {
|
||||
| Self::DatabaseSources
|
||||
| Self::VcsWrite
|
||||
| Self::Net(_)
|
||||
| Self::NetAsk
|
||||
| Self::NetHttp(_)
|
||||
| Self::Secrets
|
||||
| Self::InputEvents
|
||||
@@ -104,6 +109,10 @@ impl Capability {
|
||||
"Copy to the clipboard from its commands, and read and write it from its web views".into()
|
||||
}
|
||||
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
|
||||
Self::NetAsk => {
|
||||
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones"
|
||||
.into()
|
||||
}
|
||||
Self::NetHttp(host) => {
|
||||
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
|
||||
}
|
||||
@@ -230,6 +239,7 @@ impl fmt::Display for Capability {
|
||||
Self::InputEvents => f.write_str("events.input"),
|
||||
Self::Clipboard => f.write_str("clipboard"),
|
||||
Self::Net(h) => write!(f, "net:{h}"),
|
||||
Self::NetAsk => f.write_str("net:ask"),
|
||||
Self::NetHttp(h) => write!(f, "net.http:{h}"),
|
||||
Self::Ipc(p) => write!(f, "ipc:{p}"),
|
||||
Self::Process(b) => write!(f, "process:{b}"),
|
||||
@@ -251,6 +261,9 @@ impl FromStr for Capability {
|
||||
}
|
||||
return match kind {
|
||||
"net" | "net.http" => {
|
||||
if kind == "net" && arg == "ask" {
|
||||
return Ok(Self::NetAsk);
|
||||
}
|
||||
let host = arg.strip_prefix("*.").unwrap_or(arg);
|
||||
let valid = !host.is_empty()
|
||||
&& host.len() <= 253
|
||||
@@ -451,6 +464,25 @@ mod command_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod net_ask_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn net_ask_is_its_own_medium_risk_grant_covering_no_host() {
|
||||
let cap: Capability = "net:ask".parse().unwrap();
|
||||
assert_eq!(cap, Capability::NetAsk);
|
||||
assert_eq!(cap.to_string(), "net:ask");
|
||||
assert_eq!(cap.risk(), Risk::Medium);
|
||||
assert!(!cap.auto_migrates());
|
||||
// It asks; it does not itself connect anywhere or pre-approve a host.
|
||||
assert!(!cap.covers(&"net:api.example.com".parse().unwrap()));
|
||||
assert!(!cap.covers(&Capability::NetHttp("example.com".into())));
|
||||
assert!(!"net:api.example.com".parse::<Capability>().unwrap().covers(&cap));
|
||||
assert_ne!("net:ask.com".parse::<Capability>().unwrap(), Capability::NetAsk);
|
||||
assert!("net:ask".parse::<Capability>().unwrap().describe().contains("host"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod database_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -53,6 +53,9 @@ pub struct Grant {
|
||||
/// Granted capabilities (`project.read`, `net:x`, …).
|
||||
#[serde(default)]
|
||||
pub granted: BTreeSet<String>,
|
||||
/// Exact hosts approved through `net:ask`, separate from manifest grants.
|
||||
#[serde(default)]
|
||||
pub net_hosts: BTreeSet<String>,
|
||||
/// Optional capabilities the user declined (not asked again until re-confirmed).
|
||||
#[serde(default)]
|
||||
pub declined: BTreeSet<String>,
|
||||
@@ -201,6 +204,9 @@ impl Ledger {
|
||||
let Some(grant) = self.grants.get_mut(&req.subject) else {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
};
|
||||
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
|
||||
grant.net_hosts.clear();
|
||||
}
|
||||
if !grant.confirmed {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
}
|
||||
@@ -225,6 +231,9 @@ impl Ledger {
|
||||
binaries: BTreeMap<String, BinaryGrant>,
|
||||
) {
|
||||
let grant = self.grants.entry(req.subject.clone()).or_default();
|
||||
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
|
||||
grant.net_hosts.clear();
|
||||
}
|
||||
grant.plugin_id = req.plugin_id.to_owned();
|
||||
grant.version = req.version.to_owned();
|
||||
grant.package_hash = req.package_hash.to_owned();
|
||||
@@ -252,6 +261,12 @@ impl Ledger {
|
||||
grant.declined.remove(&s);
|
||||
grant.granted.insert(s)
|
||||
} else {
|
||||
if let Capability::Net(host) = cap {
|
||||
return grant.net_hosts.remove(host) | grant.granted.remove(&s);
|
||||
}
|
||||
if cap == &Capability::NetAsk {
|
||||
grant.net_hosts.clear();
|
||||
}
|
||||
if let Capability::Process(_) = cap {
|
||||
grant.binaries.remove(&s);
|
||||
for binaries in grant.project_binaries.values_mut() {
|
||||
@@ -262,6 +277,19 @@ impl Ledger {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remembers one exact host without granting a manifest capability.
|
||||
pub fn remember_net_host(&mut self, subject: &Subject, host: &str) -> bool {
|
||||
let Some(grant) = self.grants.get_mut(subject) else { return false };
|
||||
if !grant.confirmed || !grant.has(&Capability::NetAsk) || host.contains('*') {
|
||||
return false;
|
||||
}
|
||||
grant.net_hosts.insert(host.to_owned())
|
||||
}
|
||||
|
||||
pub fn forget_net_host(&mut self, subject: &Subject, host: &str) -> bool {
|
||||
self.grants.get_mut(subject).is_some_and(|g| g.net_hosts.remove(host))
|
||||
}
|
||||
|
||||
/// Records the program approved for a `process:` grant.
|
||||
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
@@ -510,6 +538,40 @@ mod tests {
|
||||
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_ask_hosts_persist_revoke_and_drop_with_the_capability() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut l = Ledger::load(tmp.path());
|
||||
let r = req("1.0.0", "h", &["net:ask"], &[]);
|
||||
l.confirm(&r, &[], BTreeMap::new());
|
||||
let host = "api.example.com";
|
||||
assert!(l.remember_net_host(&r.subject, host));
|
||||
assert!(!l.remember_net_host(&r.subject, "*.example.com"));
|
||||
assert!(!l.get(&r.subject).unwrap().has(&Capability::Net(host.into())));
|
||||
l.save().unwrap();
|
||||
l = Ledger::load(tmp.path());
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
|
||||
l.confirm(&r, &[], BTreeMap::new());
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
|
||||
assert!(l.set(&r.subject, &Capability::Net(host.into()), false));
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
|
||||
|
||||
for confirm in [false, true] {
|
||||
l.remember_net_host(&r.subject, host);
|
||||
// A newly declared host must not inherit an ask grant after net:ask is removed.
|
||||
let update = req("2.0.0", "h2", &["project.read", "net:api.example.com"], &[]);
|
||||
if confirm {
|
||||
l.confirm(&update, &[], BTreeMap::new());
|
||||
} else {
|
||||
assert!(matches!(l.decide(&update), Decision::NeedsConfirmation { .. }));
|
||||
}
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
|
||||
}
|
||||
l.remember_net_host(&r.subject, host);
|
||||
l.set(&r.subject, &Capability::NetAsk, false);
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_grants_low_risk_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -338,6 +338,9 @@ impl Manifest {
|
||||
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 3)?;
|
||||
}
|
||||
if matches!(cap, Capability::NetAsk) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 5)?;
|
||||
}
|
||||
}
|
||||
if let Some(dark) = &self.icon_dark {
|
||||
if self.icon.is_none() {
|
||||
@@ -718,3 +721,24 @@ mod commands_api_tests {
|
||||
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod net_ask_api_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn net_ask_requires_api_1_5() {
|
||||
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
|
||||
for engine in ["^1.4", "^1.5"] {
|
||||
let manifest = format!(
|
||||
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
|
||||
);
|
||||
let parsed = Manifest::parse(&manifest);
|
||||
if engine == "^1.5" {
|
||||
parsed.unwrap();
|
||||
} else {
|
||||
assert!(parsed.unwrap_err().contains("new in plugin API 1.5"), "{engine}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ async fn debug_start_inner(
|
||||
let settings = settings.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_sdk::go::prepare_debug_compiler(&mut config.env, &config.extra_json)?;
|
||||
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
|
||||
ide_sdk::go::apply_to_debug(
|
||||
&mut config.env,
|
||||
|
||||
@@ -922,12 +922,13 @@ fn plugin_file_edits(
|
||||
use ide_plugin::FileOperation;
|
||||
let confine = !a.operations.is_empty() || a.open.is_some();
|
||||
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
|
||||
let writable = |path: &Path| ide_plugin::resolve_writable_in_root(root, &path.to_string_lossy());
|
||||
let mut files = Vec::new();
|
||||
let mut created_with_text = Vec::new();
|
||||
for operation in &a.operations {
|
||||
match operation {
|
||||
FileOperation::Create { path, text, overwrite } => {
|
||||
let path = confined(path)?;
|
||||
let path = writable(path)?;
|
||||
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
|
||||
if !text.is_empty() {
|
||||
let start = Range::point(Pos::new(0, 0));
|
||||
@@ -948,7 +949,7 @@ fn plugin_file_edits(
|
||||
}
|
||||
}
|
||||
for (path, edits) in &a.edits {
|
||||
let path = if confine { confined(path)? } else { path.clone() };
|
||||
let path = if confine { writable(path)? } else { path.clone() };
|
||||
if created_with_text.contains(&path) {
|
||||
return Err("put the text of a created file in its `create`, not in an edit".into());
|
||||
}
|
||||
@@ -2228,6 +2229,55 @@ mod tests {
|
||||
assert!(without_generated_helpers(Vec::new()).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn plugins_file_actions_confine_content_writes_and_preserve_link_entries() {
|
||||
use ide_core::FileEdit;
|
||||
use ide_plugin::{CodeAction, FileOperation};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
std::fs::write(root.join("target.txt"), "inside").unwrap();
|
||||
for (name, target) in [
|
||||
("outside", outside.clone()),
|
||||
("dangling", tmp.path().join("missing.txt")),
|
||||
("inside", root.join("target.txt")),
|
||||
] {
|
||||
std::os::unix::fs::symlink(target, root.join(name)).unwrap();
|
||||
}
|
||||
let action = |operations, edits| CodeAction {
|
||||
title: "Update files".into(),
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits,
|
||||
operations,
|
||||
open: Some(root.join("target.txt")),
|
||||
data: None,
|
||||
file_action: true,
|
||||
};
|
||||
for name in ["outside", "dangling"] {
|
||||
let create = FileOperation::Create { path: root.join(name), text: "text".into(), overwrite: true };
|
||||
assert!(plugin_file_edits(&root, &action(vec![create], Vec::new())).is_err());
|
||||
let edits = vec![(root.join(name), vec![TextEdit { range: Range::default(), new_text: "text".into() }])];
|
||||
assert!(plugin_file_edits(&root, &action(Vec::new(), edits)).is_err());
|
||||
}
|
||||
let create = FileOperation::Create { path: root.join("inside"), text: "text".into(), overwrite: true };
|
||||
let (files, _) = plugin_file_edits(&root, &action(vec![create], Vec::new())).unwrap();
|
||||
assert!(matches!(&files[0], FileEdit::Create { path, .. } if **path == root.join("target.txt")));
|
||||
let operations = vec![
|
||||
FileOperation::Rename { from: root.join("outside"), to: root.join("dangling") },
|
||||
FileOperation::Delete { path: root.join("outside") },
|
||||
];
|
||||
let (files, _) = plugin_file_edits(&root, &action(operations, Vec::new())).unwrap();
|
||||
assert!(matches!(&files[0], FileEdit::Rename { from, to }
|
||||
if **from == root.join("outside") && **to == root.join("dangling")));
|
||||
assert!(matches!(&files[1], FileEdit::Delete { path, .. } if **path == root.join("outside")));
|
||||
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
|
||||
use ide_core::FileEdit;
|
||||
|
||||
@@ -6,6 +6,7 @@ use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{CancellationToken, TextEdit};
|
||||
use ide_extension::Capability;
|
||||
use ide_extension::Package;
|
||||
use ide_extension::ledger::{Ledger, Subject};
|
||||
use ide_language::ProgramPin;
|
||||
use ide_plugin::{
|
||||
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
|
||||
@@ -17,7 +18,7 @@ use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command as StdCommand, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Largest storage of one plugin (keys + values).
|
||||
@@ -163,16 +164,10 @@ impl SecretStore for FileSecretStore {
|
||||
None => map.remove(name),
|
||||
};
|
||||
let file = self.file(plugin);
|
||||
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
|
||||
ide_vfs::create_private_dir(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
|
||||
let bytes = serde_json::to_vec(&map).map_err(|e| e.to_string())?;
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
std::fs::rename(&tmp, &file).map_err(|e| e.to_string())
|
||||
replace_entry(&file, &bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +496,10 @@ pub(super) struct Shared {
|
||||
pub(super) prompts: Mutex<HashMap<u64, PromptEntry>>,
|
||||
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
|
||||
pub(super) prompts_blocked: Mutex<BTreeSet<String>>,
|
||||
/// One network question per plugin; refusals throttle all hosts for 30 seconds.
|
||||
net_prompts: Mutex<HashMap<String, Option<Instant>>>,
|
||||
pub net_hosts_session_only: AtomicBool,
|
||||
session_net_hosts: Mutex<HashMap<String, BTreeSet<String>>>,
|
||||
pub(super) next_prompt: AtomicU64,
|
||||
pub(super) run_configurations: super::run_detectors::DynamicConfigurations,
|
||||
pub(super) run_targets: Mutex<super::run_targets::Targets>,
|
||||
@@ -524,6 +523,9 @@ pub(super) struct Shared {
|
||||
pub secrets: RwLock<Arc<dyn SecretStore>>,
|
||||
/// (plugin, `process:` capability) → the approved program.
|
||||
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
/// The grant ledger, shared with the [`super::PluginHost`]: `net:ask`
|
||||
/// looks up and records "always allow" hosts right where a request runs.
|
||||
pub ledger: Arc<Mutex<Ledger>>,
|
||||
pub project_pins: ProjectPins,
|
||||
pub event_editors: RwLock<HashMap<String, ide_plugin::EditorContext>>,
|
||||
/// Validated UI declarations (settings schemas, declared views).
|
||||
@@ -548,6 +550,7 @@ impl Shared {
|
||||
data_dir: PathBuf,
|
||||
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
ui: Arc<ide_extension::UiStore>,
|
||||
ledger: Arc<Mutex<Ledger>>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ui,
|
||||
@@ -556,6 +559,7 @@ impl Shared {
|
||||
workspace: RwLock::new(None),
|
||||
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
|
||||
pins,
|
||||
ledger,
|
||||
project_pins: Arc::default(),
|
||||
event_editors: RwLock::default(),
|
||||
data_dir,
|
||||
@@ -568,6 +572,9 @@ impl Shared {
|
||||
process_shutdowns: Mutex::default(),
|
||||
prompts: Mutex::default(),
|
||||
prompts_blocked: Mutex::default(),
|
||||
net_prompts: Mutex::default(),
|
||||
net_hosts_session_only: AtomicBool::new(false),
|
||||
session_net_hosts: Mutex::default(),
|
||||
next_prompt: AtomicU64::new(1),
|
||||
run_configurations: Arc::default(),
|
||||
run_targets: Mutex::default(),
|
||||
@@ -799,6 +806,8 @@ impl Shared {
|
||||
self.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: plugin.into(), sets: Vec::new() });
|
||||
}
|
||||
self.withdraw_prompts(plugin);
|
||||
self.net_prompts.lock().remove(plugin);
|
||||
self.session_net_hosts.lock().remove(plugin);
|
||||
let removed: Vec<(Option<String>, String)> = {
|
||||
let mut contexts = self.contexts.lock();
|
||||
let keys: Vec<_> = contexts.keys().filter(|(_, p, _)| p == plugin).cloned().collect();
|
||||
@@ -924,6 +933,15 @@ fn storage_io(e: std::io::Error) -> HostError {
|
||||
HostError::Failed(format!("storage: {e}"))
|
||||
}
|
||||
|
||||
fn replace_entry(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut tmp =
|
||||
tempfile::Builder::new().prefix(".entry-").suffix(".tmp").tempfile_in(path.parent().expect("has parent"))?;
|
||||
tmp.write_all(bytes)?;
|
||||
tmp.persist(path).map_err(|e| e.error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A key's file name: its hash, since keys may hold any character.
|
||||
fn entry_name(key: &str) -> String {
|
||||
blake3::hash(key.as_bytes()).to_hex().to_string()
|
||||
@@ -945,8 +963,7 @@ fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
|
||||
bytes.extend_from_slice(&len.to_le_bytes());
|
||||
bytes.extend_from_slice(key.as_bytes());
|
||||
bytes.extend_from_slice(value.as_bytes());
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
|
||||
replace_entry(&file, &bytes).map_err(storage_io)
|
||||
}
|
||||
|
||||
/// The stored entries' files and the bytes of their keys and values.
|
||||
@@ -983,6 +1000,9 @@ impl PluginApi {
|
||||
/// older version into it.
|
||||
fn storage(&self) -> Result<PathBuf, HostError> {
|
||||
let dir = self.shared.storage_dir(self.id());
|
||||
std::fs::create_dir_all(&self.shared.data_dir).map_err(storage_io)?;
|
||||
ide_vfs::create_private_dir(dir.parent().expect("has parent")).map_err(failed)?;
|
||||
ide_vfs::create_private_dir(&dir).map_err(failed)?;
|
||||
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
|
||||
match std::fs::read(&legacy) {
|
||||
Ok(bytes) => {
|
||||
@@ -2211,6 +2231,90 @@ impl HostApi for PluginApi {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `net:ask` answer: a host the user allowed with "always" is
|
||||
/// remembered in the ledger (no dialog again, background included); a new
|
||||
/// host is asked about in `client`'s window when there is one. Dismissal
|
||||
/// and refusal both deny and throttle further questions.
|
||||
fn net_ask(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
client: Option<&str>,
|
||||
host: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<bool>, HostError> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
if !self.pkg.manifest.declares(&Capability::NetAsk) {
|
||||
return Ok(None);
|
||||
}
|
||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||
{
|
||||
let ledger = self.shared.ledger.lock();
|
||||
if let Some(grant) = ledger.get(subject)
|
||||
&& grant.confirmed
|
||||
&& grant.has(&Capability::NetAsk)
|
||||
&& grant.net_hosts.contains(&host)
|
||||
{
|
||||
return Ok(Some(true));
|
||||
}
|
||||
}
|
||||
if self.shared.session_net_hosts.lock().get(self.id()).is_some_and(|hosts| hosts.contains(&host)) {
|
||||
return Ok(Some(true));
|
||||
}
|
||||
let Some(client) = client else { return Ok(None) };
|
||||
{
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
if closed.contains(client) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
if self.shared.prompts_blocked.lock().contains(self.id()) {
|
||||
return Ok(None);
|
||||
}
|
||||
{
|
||||
let mut prompts = self.shared.net_prompts.lock();
|
||||
if prompts.get(self.id()).is_some_and(|until| until.is_none_or(|until| until > Instant::now())) {
|
||||
return Ok(None);
|
||||
}
|
||||
prompts.insert(self.id().to_owned(), None);
|
||||
}
|
||||
let session_only = self.shared.net_hosts_session_only.load(Ordering::Relaxed);
|
||||
let answer = self.ask(Some(client), Prompt::NetPermission { host: host.clone(), session_only }, cancel);
|
||||
let result = match answer {
|
||||
Ok(Some(keys)) if keys == ["session"] || keys == ["always"] => {
|
||||
let mut ledger = self.shared.ledger.lock();
|
||||
if cancel.is_cancelled() {
|
||||
Err(HostError::Cancelled)
|
||||
} else if ledger.get(subject).is_some_and(|g| g.confirmed && g.has(&Capability::NetAsk)) {
|
||||
if session_only || keys == ["session"] {
|
||||
self.shared.session_net_hosts.lock().entry(self.id().into()).or_default().insert(host);
|
||||
} else {
|
||||
ledger.remember_net_host(subject, &host);
|
||||
if let Err(e) = ledger.save() {
|
||||
tracing::warn!("saving plugin grants: {e}");
|
||||
}
|
||||
}
|
||||
drop(ledger);
|
||||
self.shared.sink.emit(None, PluginEvent::CommandsChanged);
|
||||
Ok(Some(true))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
Ok(Some(keys)) if keys == ["once"] => Ok(Some(false)),
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let mut prompts = self.shared.net_prompts.lock();
|
||||
if matches!(result, Ok(Some(_))) {
|
||||
prompts.remove(self.id());
|
||||
} else {
|
||||
prompts.insert(self.id().to_owned(), Some(Instant::now() + Duration::from_secs(30)));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn secret_is_set(&self, name: &str) -> bool {
|
||||
self.shared.secrets.read().get(self.id(), name).is_some()
|
||||
}
|
||||
@@ -2682,7 +2786,9 @@ impl HostApi for PluginApi {
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false),
|
||||
Err(e) => return Err(not_writable(real, e)),
|
||||
};
|
||||
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
|
||||
let private = self.plugin_dir().is_ok_and(|dir| real.starts_with(dir));
|
||||
check_write_path(real)?;
|
||||
if private {
|
||||
let bytes = ide_vfs::encode(text, &encoding, has_bom).map_err(|e| not_writable(real, e))?;
|
||||
ide_vfs::write_atomic_private(real, &bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
} else {
|
||||
@@ -2692,7 +2798,9 @@ impl HostApi for PluginApi {
|
||||
|
||||
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> {
|
||||
check_unsaved(self.shared.workspace(), real)?;
|
||||
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
|
||||
let private = self.plugin_dir().is_ok_and(|dir| real.starts_with(dir));
|
||||
check_write_path(real)?;
|
||||
if private {
|
||||
ide_vfs::write_atomic_private(real, bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
} else {
|
||||
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
@@ -2700,6 +2808,16 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Approved write paths are already resolved. Refuse substitutions since approval.
|
||||
fn check_write_path(path: &Path) -> Result<(), HostError> {
|
||||
let parent = path.parent().ok_or_else(|| HostError::Invalid("no directory to put it in".into()))?;
|
||||
let checked = ide_plugin::resolve_writable_in_root(parent, &to_ipc(path)).map_err(HostError::Invalid)?;
|
||||
if checked != path {
|
||||
return Err(HostError::Invalid(format!("{}: file path changed before writing", path.display())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file that is not there, or cannot be read, as the plugin sees it.
|
||||
fn not_a_file(path: &Path, e: std::io::Error) -> HostError {
|
||||
match e.kind() {
|
||||
@@ -3099,10 +3217,127 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let sink: Arc<dyn PluginEventSink> = Arc::new(|_: Option<&str>, _: PluginEvent| {});
|
||||
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new());
|
||||
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new(), Arc::default());
|
||||
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_questions_are_exclusive_throttled_and_cancelled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut api = api(dir.path(), Arc::default());
|
||||
api.pkg = Package::new(
|
||||
ide_extension::Manifest::parse(
|
||||
"id = \"acme.lint\"\nname = \"Lint\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n[capabilities]\nrequired = [\"net:ask\"]\n",
|
||||
).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
let subject = Subject::Installed(api.id().into());
|
||||
api.shared.ledger.lock().confirm(
|
||||
&ide_extension::ledger::Request {
|
||||
subject: subject.clone(),
|
||||
plugin_id: api.id(),
|
||||
version: "1.0.0",
|
||||
package_hash: "h",
|
||||
required: vec![Capability::NetAsk],
|
||||
optional: vec![],
|
||||
},
|
||||
&[],
|
||||
BTreeMap::new(),
|
||||
);
|
||||
for answer in [Some(vec!["always".into(), "once".into()]), None] {
|
||||
api.shared.net_prompts.lock().clear();
|
||||
let cancel = CancellationToken::new();
|
||||
std::thread::scope(|scope| {
|
||||
let run = scope.spawn(|| api.net_ask(&subject, Some("main"), "::1", &cancel));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let id = loop {
|
||||
if let Some(id) = api.shared.prompts.lock().keys().next().copied() {
|
||||
break id;
|
||||
}
|
||||
assert!(Instant::now() < deadline);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
assert_eq!(api.net_ask(&subject, Some("other"), "other.example", &cancel).unwrap(), None);
|
||||
if let Some(answer) = answer {
|
||||
assert!(api.shared.answer_prompt(id, Some(answer)));
|
||||
assert_eq!(run.join().unwrap().unwrap(), None);
|
||||
} else {
|
||||
cancel.cancel();
|
||||
assert!(matches!(run.join().unwrap(), Err(HostError::Cancelled)));
|
||||
}
|
||||
});
|
||||
assert!(api.shared.prompts.lock().is_empty());
|
||||
assert!(api.shared.ledger.lock().get(&subject).unwrap().net_hosts.is_empty());
|
||||
assert_eq!(api.net_ask(&subject, Some("main"), "new.example", &CancellationToken::new()).unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writes_recheck_approved_paths_after_symlink_substitution() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir_all(root.join("nested")).unwrap();
|
||||
let path = ide_plugin::resolve_writable_in_root(&root, "nested/file.txt").unwrap();
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
symlink(&outside, &path).unwrap();
|
||||
assert!(api.write_text_file(&path, "text").is_err());
|
||||
assert!(api.write_bytes_file(&path, b"bytes").is_err());
|
||||
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
|
||||
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::fs::remove_dir(root.join("nested")).unwrap();
|
||||
symlink(tmp.path(), root.join("nested")).unwrap();
|
||||
assert!(api.write_text_file(&path, "text").is_err());
|
||||
assert!(api.write_bytes_file(&path, b"bytes").is_err());
|
||||
assert!(!tmp.path().join("file.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn storage_and_secrets_do_not_write_through_temporary_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
let dir = api.storage().unwrap();
|
||||
let entry = dir.join(entry_name("key"));
|
||||
symlink(&outside, entry.with_extension("tmp")).unwrap();
|
||||
symlink(&outside, &entry).unwrap();
|
||||
api.storage_set("key", Some("value".into())).unwrap();
|
||||
assert_eq!(api.storage_get("key").unwrap().as_deref(), Some("value"));
|
||||
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
|
||||
|
||||
let secrets = FileSecretStore { dir: tmp.path().join("secrets") };
|
||||
secrets.set("acme.lint", "key", Some("before")).unwrap();
|
||||
symlink(&outside, secrets.file("acme.lint").with_extension("tmp")).unwrap();
|
||||
secrets.set("acme.lint", "key", Some("after")).unwrap();
|
||||
assert_eq!(secrets.get("acme.lint", "key").as_deref(), Some("after"));
|
||||
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn storage_and_secrets_refuse_symlinked_private_directories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let dir = api.storage().unwrap();
|
||||
std::fs::remove_dir(&dir).unwrap();
|
||||
let outside = tmp.path().join("outside");
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, &dir).unwrap();
|
||||
assert!(api.storage_set("key", Some("value".into())).is_err());
|
||||
let secrets = FileSecretStore { dir: tmp.path().join("secrets") };
|
||||
std::fs::create_dir(&secrets.dir).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, secrets.dir.join("acme.lint")).unwrap();
|
||||
assert!(secrets.set("acme.lint", "key", Some("value")).is_err());
|
||||
assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn private_directory_matches_resolved_write_paths() {
|
||||
@@ -3716,6 +3951,7 @@ mod workbench_tests {
|
||||
root.join(".data"),
|
||||
Arc::default(),
|
||||
ide_extension::UiStore::new(),
|
||||
Arc::default(),
|
||||
);
|
||||
*shared.workspace.write() =
|
||||
Some(Arc::new(Workspace { root: root.into(), trusted, text: text.map(str::to_owned) }));
|
||||
|
||||
@@ -992,7 +992,9 @@ mod tests {
|
||||
let put = |product: &str, file: &str, seconds| {
|
||||
let path = root.join(product).join(file);
|
||||
write(&path, b"<application/>");
|
||||
std::fs::File::open(path)
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.unwrap()
|
||||
.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds))
|
||||
.unwrap();
|
||||
|
||||
@@ -380,7 +380,7 @@ pub struct PluginHost {
|
||||
diagnostic_projects: Mutex<Vec<diagnostics::ProjectState>>,
|
||||
/// Validated UI declarations of installed packages (start-up reuses them).
|
||||
index: Arc<ContributionIndex>,
|
||||
ledger: Mutex<Ledger>,
|
||||
ledger: Arc<Mutex<Ledger>>,
|
||||
plugins: RwLock<BTreeMap<String, Arc<LoadedPlugin>>>,
|
||||
state: Mutex<PersistedState>,
|
||||
/// What every plugin's [`api::PluginApi`] shares: sink, registry,
|
||||
@@ -452,7 +452,9 @@ impl PluginHost {
|
||||
languages.set_process_policy(Arc::new(move |plugin, cap| {
|
||||
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
|
||||
}));
|
||||
let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone());
|
||||
let ledger = Arc::new(Mutex::new(Ledger::load(&plugins_dir)));
|
||||
let shared =
|
||||
api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone(), ledger.clone());
|
||||
let official_keys = market::built_in_official_keys();
|
||||
let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys));
|
||||
let host = Self {
|
||||
@@ -463,7 +465,7 @@ impl PluginHost {
|
||||
updates: Mutex::default(),
|
||||
project_policy: RwLock::new(Arc::new(|_| false)),
|
||||
engine: PluginEngine::new().map_err(IdeError::internal)?,
|
||||
ledger: Mutex::new(Ledger::load(&plugins_dir)),
|
||||
ledger,
|
||||
plugins_dir,
|
||||
sink,
|
||||
limits,
|
||||
@@ -806,6 +808,11 @@ impl PluginHost {
|
||||
Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect())
|
||||
}
|
||||
|
||||
/// Keeps hosts allowed through `net:ask` in memory for this host's lifetime.
|
||||
pub fn set_net_hosts_session_only(&self) {
|
||||
self.shared.net_hosts_session_only.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The UI language windows run in, as `locale()` reports it to plugin code.
|
||||
pub fn set_ui_locale(&self, tag: &str) {
|
||||
*self.shared.locale.write() = tag.to_owned();
|
||||
@@ -1079,19 +1086,39 @@ impl PluginHost {
|
||||
self.info_of(id)
|
||||
}
|
||||
|
||||
/// Grants or revokes one capability (Settings › Plugins › Permissions).
|
||||
/// Revokes one exact host remembered through `net:ask`.
|
||||
pub fn revoke_net_host(&self, id: &str, host: &str) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
{
|
||||
let mut ledger = self.ledger.lock();
|
||||
if !ledger.forget_net_host(&plugin.subject(), host) {
|
||||
return Err(IdeError::invalid("host is not remembered for this plugin"));
|
||||
}
|
||||
self.save_ledger(&ledger);
|
||||
}
|
||||
self.stop(&plugin);
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
|
||||
/// Grants or revokes one declared capability (Settings › Plugins › Permissions).
|
||||
/// Revoking a required one stops the plugin until consent is given again.
|
||||
pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
|
||||
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
|
||||
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
|
||||
}
|
||||
{
|
||||
let mut ledger = self.ledger.lock();
|
||||
let subject = plugin.subject();
|
||||
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
|
||||
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
|
||||
}
|
||||
if ledger.get(&subject).is_none() {
|
||||
return Err(IdeError::invalid(format!("plugin {id:?} has not been confirmed yet")));
|
||||
}
|
||||
@@ -2537,6 +2564,7 @@ impl PluginHost {
|
||||
plugin_id: plugin.id().to_owned(),
|
||||
version: plugin.manifest().version.clone(),
|
||||
granted,
|
||||
subject: plugin.subject(),
|
||||
host: api,
|
||||
limits: self.limits,
|
||||
};
|
||||
@@ -2914,6 +2942,13 @@ impl PluginHost {
|
||||
capabilities.push(CapabilityInfo::new(extra, true, granted(extra)));
|
||||
}
|
||||
}
|
||||
// Hosts remembered through `net:ask` are grants of their own: listed
|
||||
// separately so the user can revoke them one by one.
|
||||
let net_hosts = grant
|
||||
.as_ref()
|
||||
.filter(|g| g.confirmed && g.has(&Capability::NetAsk) && m.declares(&Capability::NetAsk))
|
||||
.map(|g| g.net_hosts.iter().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
PluginInfo {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
@@ -2930,6 +2965,7 @@ impl PluginHost {
|
||||
error: meta.error.clone(),
|
||||
has_code: m.wasm.is_some(),
|
||||
capabilities,
|
||||
net_hosts,
|
||||
contributions: self.registry.contributions(&m.id),
|
||||
activation: m.activation.iter().map(ToString::to_string).collect(),
|
||||
warnings: meta.warnings.clone(),
|
||||
|
||||
@@ -292,6 +292,7 @@ impl RemotePlugins {
|
||||
}
|
||||
};
|
||||
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
|
||||
host.set_net_hosts_session_only();
|
||||
projects.set_plugin_host(&host);
|
||||
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
|
||||
host.set_secret_store(Arc::new(ClientSecrets { peer: peer.clone(), runtime: projects.runtime.clone() }));
|
||||
|
||||
@@ -72,6 +72,9 @@ pub struct PluginInfo {
|
||||
pub has_code: bool,
|
||||
/// Declared capabilities with risk and grant state.
|
||||
pub capabilities: Vec<CapabilityInfo>,
|
||||
/// HTTPS hosts the user allowed through `net:ask` ("always allow"),
|
||||
/// none of them declared by the manifest; each can be revoked.
|
||||
pub net_hosts: Vec<String>,
|
||||
/// What it contributes while live (point + label).
|
||||
pub contributions: Vec<ContributionInfo>,
|
||||
/// `activation` events of the manifest.
|
||||
|
||||
@@ -420,6 +420,7 @@ pub fn projectless_command_allowed(cmd: &str) -> bool {
|
||||
| "plugin_set_enabled"
|
||||
| "plugin_grant"
|
||||
| "plugin_set_capability"
|
||||
| "plugin_revoke_net_host"
|
||||
| "plugin_choose_program"
|
||||
| "plugin_program"
|
||||
| "plugin_roll_back"
|
||||
|
||||
@@ -59,16 +59,15 @@ struct Checks {
|
||||
hover: (At, &'static str),
|
||||
/// Completion at the end of the typed line offers this item.
|
||||
completion: (Typed, &'static str),
|
||||
/// The typed line is reported with this diagnostic; the quick fix with this title makes the
|
||||
/// file contain the last text.
|
||||
/// The typed line is reported with a diagnostic, and its quick fix applies.
|
||||
quick_fix: QuickFix,
|
||||
/// With the word selected, this intention is offered with an edit or command.
|
||||
intention: (At, &'static str),
|
||||
intention: Option<(At, &'static str)>,
|
||||
/// Formatting turns the typed line into the expected one.
|
||||
format: (Typed, &'static str),
|
||||
indent: Indent,
|
||||
/// Renaming the symbol to the new name edits these files.
|
||||
rename: (At, &'static str, &'static [&'static str]),
|
||||
rename: Option<(At, &'static str, &'static [&'static str])>,
|
||||
/// Parameter info in a call being typed: a signature naming this parameter.
|
||||
signature: (Typed, &'static str),
|
||||
/// The Structure view of a file: these symbols, at any depth.
|
||||
@@ -104,8 +103,8 @@ enum Imports {
|
||||
struct QuickFix {
|
||||
typed: Typed,
|
||||
diagnostic: &'static str,
|
||||
title: &'static str,
|
||||
result: &'static str,
|
||||
/// The quick fix with this title makes the file contain this text.
|
||||
fix: Option<(&'static str, &'static str)>,
|
||||
}
|
||||
|
||||
/// The editor's indentation, sent with a format request.
|
||||
@@ -135,7 +134,7 @@ fn copy_dir(from: &Path, to: &Path) {
|
||||
for entry in std::fs::read_dir(from).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let name = entry.file_name();
|
||||
if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv")) {
|
||||
if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv" | "vendor")) {
|
||||
continue;
|
||||
}
|
||||
let target = to.join(&name);
|
||||
@@ -392,19 +391,21 @@ impl Smoke {
|
||||
.unwrap_or_else(|e| self.fail(check, format_args!("resolving {title:?}: {e}")))
|
||||
}
|
||||
|
||||
async fn quick_fix(&self, fix: QuickFix) {
|
||||
let (doc, _, before) = self.type_line(fix.typed).await;
|
||||
assert!(!before.contains(fix.result), "{fix:?}: the file already contains the result");
|
||||
let range = self.diagnostic(fix.typed.file, fix.diagnostic).await;
|
||||
let action = self.action("quick fix", doc, range, fix.title).await;
|
||||
ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![])
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {:?}: {e}", fix.title)));
|
||||
self.retry("quick fix", Duration::from_secs(10), || async {
|
||||
let text = self.doc(fix.typed.file).await.1;
|
||||
if text.contains(fix.result) { Ok(()) } else { Err(text) }
|
||||
})
|
||||
.await;
|
||||
async fn quick_fix(&self, q: QuickFix) {
|
||||
let (doc, _, before) = self.type_line(q.typed).await;
|
||||
let range = self.diagnostic(q.typed.file, q.diagnostic).await;
|
||||
if let Some((title, result)) = q.fix {
|
||||
assert!(!before.contains(result), "{q:?}: the file already contains the result");
|
||||
let action = self.action("quick fix", doc, range, title).await;
|
||||
ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![])
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {title:?}: {e}")));
|
||||
self.retry("quick fix", Duration::from_secs(10), || async {
|
||||
let text = self.doc(q.typed.file).await.1;
|
||||
if text.contains(result) { Ok(()) } else { Err(text) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
@@ -619,7 +620,9 @@ impl Smoke {
|
||||
self.hover(c.hover.0, c.hover.1).await;
|
||||
self.completion(c.completion.0, c.completion.1).await;
|
||||
self.quick_fix(c.quick_fix).await;
|
||||
self.intention(c.intention.0, c.intention.1).await;
|
||||
if let Some((on, title)) = c.intention {
|
||||
self.intention(on, title).await;
|
||||
}
|
||||
self.format(c.format.0, c.indent, c.format.1).await;
|
||||
self.signature(c.signature.0, c.signature.1).await;
|
||||
self.outline(c.outline.0, c.outline.1).await;
|
||||
@@ -627,7 +630,9 @@ impl Smoke {
|
||||
if let Some((import, expect)) = c.organize_imports {
|
||||
self.organize_imports(import, expect).await;
|
||||
}
|
||||
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
|
||||
if let Some((symbol, to, files)) = c.rename {
|
||||
self.rename(symbol, to, files).await;
|
||||
}
|
||||
// Last: it moves a file the other checks use.
|
||||
if let Some((from, to, referencing, result)) = c.file_move {
|
||||
self.file_move(from, to, referencing, result).await;
|
||||
@@ -656,21 +661,20 @@ const TYPESCRIPT: Checks = Checks {
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("src/index.ts", "const total = cart.total();", " console.log(addMoney(total, total));"),
|
||||
diagnostic: "Cannot find name 'addMoney'",
|
||||
title: "import from \"./money.ts\"",
|
||||
result: "import { addMoney, cents, formatMoney } from \"./money.ts\";",
|
||||
fix: Some(("import from \"./money.ts\"", "import { addMoney, cents, formatMoney } from \"./money.ts\";")),
|
||||
},
|
||||
intention: (at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant"),
|
||||
intention: Some((at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant")),
|
||||
format: (
|
||||
typed("src/index.ts", "const total = cart.total();", " const shown={total,count:cart.count}"),
|
||||
" const shown = { total, count: cart.count }",
|
||||
),
|
||||
indent: Indent::Spaces(2),
|
||||
rename: (
|
||||
rename: Some((
|
||||
// From a usage in another file: the declaration is renamed too, not aliased in the import.
|
||||
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
|
||||
"formatPrice",
|
||||
&["src/index.ts", "src/money.ts"],
|
||||
),
|
||||
)),
|
||||
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
|
||||
outline: ("src/cart.ts", &["Cart", "add", "total"]),
|
||||
auto_import: AutoImport {
|
||||
@@ -713,21 +717,20 @@ const PYTHON: Checks = Checks {
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " print(Task)"),
|
||||
diagnostic: "\"Task\" is not defined",
|
||||
title: "from .models import Task",
|
||||
result: "from .models import Priority, Task, TaskList",
|
||||
fix: Some(("from .models import Task", "from .models import Priority, Task, TaskList")),
|
||||
},
|
||||
// basedpyright has no refactorings; Ruff's fixes are the Python intentions.
|
||||
intention: (at("src/tasks/models.py", "import os", "os"), "Remove unused import"),
|
||||
intention: Some((at("src/tasks/models.py", "import os", "os"), "Remove unused import")),
|
||||
format: (
|
||||
typed("src/tasks/cli.py", "tasks = build_demo()", " shown2 = {'a':1,'b': 2}"),
|
||||
" shown2 = {\"a\": 1, \"b\": 2}",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (
|
||||
rename: Some((
|
||||
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
||||
"TaskBoard",
|
||||
&["src/tasks/cli.py", "src/tasks/models.py"],
|
||||
),
|
||||
)),
|
||||
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
|
||||
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
|
||||
auto_import: AutoImport {
|
||||
@@ -768,16 +771,22 @@ const RUST: Checks = Checks {
|
||||
" let _blank = parser::ParseError { line: 1 };",
|
||||
),
|
||||
diagnostic: "missing structure fields",
|
||||
title: "Fill struct fields",
|
||||
result: "ParseError { line: 1, message",
|
||||
fix: Some(("Fill struct fields", "ParseError { line: 1, message")),
|
||||
},
|
||||
intention: (at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"), "Extract into variable"),
|
||||
intention: Some((
|
||||
at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"),
|
||||
"Extract into variable",
|
||||
)),
|
||||
format: (
|
||||
typed("src/main.rs", "let mut inventory = Inventory::new();", " let _spaced=( 1,2 );"),
|
||||
" let _spaced = (1, 2);",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
|
||||
rename: Some((
|
||||
at("src/parser.rs", "pub fn parse(text: &str)", "parse"),
|
||||
"parse_all",
|
||||
&["src/main.rs", "src/parser.rs"],
|
||||
)),
|
||||
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
|
||||
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
|
||||
auto_import: AutoImport {
|
||||
@@ -816,17 +825,16 @@ const GO: Checks = Checks {
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("cmd/server/main.go", "s := store.New()", "\tfmt.Println(s)"),
|
||||
diagnostic: "undefined: fmt",
|
||||
title: "\"fmt\"",
|
||||
result: "\t\"fmt\"",
|
||||
fix: Some(("\"fmt\"", "\t\"fmt\"")),
|
||||
},
|
||||
intention: (at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract"),
|
||||
intention: Some((at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract")),
|
||||
format: (typed("cmd/server/main.go", "s := store.New()", "\t_ = []int{1,2}"), "\t_ = []int{1, 2}"),
|
||||
indent: Indent::Tabs,
|
||||
rename: (
|
||||
rename: Some((
|
||||
at("internal/store/store.go", "func New() *Store", "New"),
|
||||
"Open",
|
||||
&["cmd/server/main.go", "internal/store/store.go"],
|
||||
),
|
||||
)),
|
||||
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
|
||||
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
|
||||
auto_import: AutoImport {
|
||||
@@ -872,13 +880,12 @@ const JAVA: Checks = Checks {
|
||||
" List<Order> all = book.byCustomer(\"alice\");",
|
||||
),
|
||||
diagnostic: "List cannot be resolved to a type",
|
||||
title: "Import 'List' (java.util)",
|
||||
result: "import java.util.List;",
|
||||
fix: Some(("Import 'List' (java.util)", "import java.util.List;")),
|
||||
},
|
||||
intention: (
|
||||
intention: Some((
|
||||
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", "book.revenueCents()"),
|
||||
"Extract to local variable",
|
||||
),
|
||||
)),
|
||||
format: (
|
||||
typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
@@ -888,11 +895,11 @@ const JAVA: Checks = Checks {
|
||||
" long doubled = revenue * 2;",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (
|
||||
rename: Some((
|
||||
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
||||
"submit",
|
||||
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
|
||||
),
|
||||
)),
|
||||
signature: (
|
||||
typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
@@ -959,23 +966,22 @@ const KOTLIN: Checks = Checks {
|
||||
" val stamp = LocalDateTime.now()",
|
||||
),
|
||||
diagnostic: "Unresolved reference",
|
||||
title: "Import",
|
||||
result: "import java.time.LocalDateTime",
|
||||
fix: Some(("Import", "import java.time.LocalDateTime")),
|
||||
},
|
||||
intention: (
|
||||
intention: Some((
|
||||
at("src/main/kotlin/com/example/library/Library.kt", "fun giveBack(isbn: String): Boolean", "giveBack"),
|
||||
"block body",
|
||||
),
|
||||
)),
|
||||
format: (
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " val pair=listOf(1,2)"),
|
||||
" val pair = listOf(1, 2)",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (
|
||||
rename: Some((
|
||||
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
|
||||
"find",
|
||||
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
|
||||
),
|
||||
)),
|
||||
signature: (
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
|
||||
"isbn",
|
||||
@@ -1003,3 +1009,51 @@ const KOTLIN: Checks = Checks {
|
||||
async fn kotlin() {
|
||||
smoke("kotlin", KOTLIN).await;
|
||||
}
|
||||
|
||||
const PHP: Checks = Checks {
|
||||
fixture: "php-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(60),
|
||||
definition: (
|
||||
at("bin/quote.php", "$customs = new ThresholdCustoms", "ThresholdCustoms"),
|
||||
at("src/Shipping/ThresholdCustoms.php", "final class ThresholdCustoms", "ThresholdCustoms"),
|
||||
),
|
||||
references: (
|
||||
at("src/Shipping/Parcel.php", "public function billableGrams", "billableGrams"),
|
||||
&["bin/quote.php", "src/Shipping/Parcel.php", "src/Shipping/Shipment.php", "src/Shipping/Tariff.php"],
|
||||
),
|
||||
hover: (
|
||||
at("bin/quote.php", "$parcel->billableGrams($shipment->route)", "billableGrams"),
|
||||
"public function billableGrams(Route $route): int",
|
||||
),
|
||||
completion: (typed("bin/quote.php", "$freightCents += $cents;", " $parcel->"), "billableGrams"),
|
||||
// Intelephense declares no code actions and no rename without a licence key.
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("bin/quote.php", "$freightCents += $cents;", " $tariff->weigh($parcel);"),
|
||||
diagnostic: "Undefined method 'weigh'",
|
||||
fix: None,
|
||||
},
|
||||
intention: None,
|
||||
format: (
|
||||
typed("bin/quote.php", "$freightCents += $cents;", " $pair=[1,2 ,3];"),
|
||||
" $pair = [1, 2, 3];",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: None,
|
||||
signature: (typed("bin/quote.php", "$freightCents += $cents;", " $tariff->freightCents("), "parcel"),
|
||||
outline: ("src/Shipping/Shipment.php", &["Shipment", "add", "heaviest", "byStore"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("bin/quote.php", "$freightCents += $cents;", " $status = ParcelSta"),
|
||||
label: "ParcelStatus",
|
||||
result: "use App\\Shipping\\ParcelStatus;",
|
||||
},
|
||||
organize_imports: None,
|
||||
// Intelephense has no `workspace/willRenameFiles`.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs intelephense"]
|
||||
async fn php() {
|
||||
smoke("php", PHP).await;
|
||||
}
|
||||
@@ -250,7 +250,7 @@ async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() {
|
||||
f.host.set_plugin_host(&plugins);
|
||||
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
|
||||
plugins.install_from_dir(&package, &Consent::all()).unwrap();
|
||||
let tool = f._tmp.path().join("wrapper");
|
||||
let tool = f.root.with_file_name("wrapper");
|
||||
std::fs::write(&tool, "#!/bin/sh\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf '<%s>\\n' \"$@\"\n").unwrap();
|
||||
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let invocation =
|
||||
@@ -370,7 +370,7 @@ async fn run_target_api15_overrides_and_frozen_api14_registration() {
|
||||
let engine = PluginEngine::new().unwrap();
|
||||
let legacy = engine.compile(&fixtures.join("run-target-api14/dist/plugin.wasm")).unwrap();
|
||||
assert_eq!(engine.check_component(&legacy).unwrap().unwrap().to_string(), "1.4.0");
|
||||
let tool = f._tmp.path().join("wrapper");
|
||||
let tool = f.root.with_file_name("wrapper");
|
||||
std::fs::write(&tool, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let invocation =
|
||||
@@ -456,7 +456,7 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
|
||||
f.host.set_plugin_host(&plugins);
|
||||
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
|
||||
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
|
||||
let tool = f._tmp.path().join("wrapper");
|
||||
let tool = f.root.with_file_name("wrapper");
|
||||
std::fs::write(&tool, "#!/bin/sh\ntouch spawned\n").unwrap();
|
||||
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
||||
|
||||
@@ -542,6 +542,30 @@ fn project_write_stays_inside_the_project() {
|
||||
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn project_writes_confine_symlink_targets() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&misbehave_with(f._tmp.path(), "misbehave-symlink-writer", "project.write"));
|
||||
let outside = f._tmp.path().join("secret.txt");
|
||||
let missing = f._tmp.path().join("missing.txt");
|
||||
symlink(&outside, f.project.join("outside.txt")).unwrap();
|
||||
symlink(&missing, f.project.join("dangling.txt")).unwrap();
|
||||
for name in ["outside.txt", "dangling.txt"] {
|
||||
let error = f.run("misbehave", "projwrite", Some(name)).unwrap_err();
|
||||
assert!(error.contains("access outside the project root is not allowed"), "{error}");
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(outside).unwrap(), "top secret");
|
||||
assert!(!missing.exists());
|
||||
for (name, target) in [("inside.txt", "src/notes.txt"), ("new.txt", "new/deep.txt")] {
|
||||
symlink(target, f.project.join(name)).unwrap();
|
||||
f.run("misbehave", "projwrite", Some(name)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(f.project.join(target)).unwrap(), "written inside the project\n");
|
||||
assert!(std::fs::symlink_metadata(f.project.join(name)).unwrap().is_symlink());
|
||||
}
|
||||
}
|
||||
|
||||
/// `fs.write` replaces files the grant covers and nothing else, through the
|
||||
/// real component.
|
||||
#[test]
|
||||
@@ -5729,6 +5753,158 @@ fn api14_denies_new_privileges_and_requires_an_invoking_window() {
|
||||
assert_eq!(f.host.declared_secret("api14", "token"), None);
|
||||
}
|
||||
|
||||
/// `net:ask`: a first request to a host outside the manifest asks the user
|
||||
/// from an invoking window ("once" allows this request, "always" remembers
|
||||
/// the host, refusal and dismissal deny). Background calls are denied without
|
||||
/// a dialog, plain HTTP still needs `net.http:<host>`, and a remembered host
|
||||
/// is listed in the permissions, where it can be revoked.
|
||||
#[test]
|
||||
fn net_ask_asks_once_remembers_always_and_revokes() {
|
||||
use ide_plugin::Prompt;
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&sample("api14"), &Consent::default()).unwrap();
|
||||
let (port, seen) = http_server("relay ok", |_| Duration::ZERO);
|
||||
let url = format!("http://127.0.0.1:{port}/");
|
||||
|
||||
// Merely declaring an optional capability is not enough: the user must grant it.
|
||||
let err = f.run("api14", "fetch", Some(&url)).unwrap_err();
|
||||
assert!(err.contains("not granted"), "{err}");
|
||||
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
f.host.set_capability("api14", "net:ask", true).unwrap();
|
||||
|
||||
// Plain HTTP elsewhere stays out of the ask flow entirely.
|
||||
let err = f.run("api14", "fetch", Some("http://example.com/")).unwrap_err();
|
||||
assert!(err.contains("net.http:example.com") && err.contains("not granted"), "{err}");
|
||||
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
|
||||
// Background (no invoking window): denied like before, no dialog.
|
||||
let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err();
|
||||
assert!(err.message.contains("net:127.0.0.1") && err.message.contains("not granted"), "{err:?}");
|
||||
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
assert!(seen.lock().is_empty(), "nothing connected");
|
||||
|
||||
// From a window: the question names the full host. "Allow once" lets
|
||||
// this request through and remembers nothing.
|
||||
let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&["once"]));
|
||||
assert_eq!(result.unwrap().as_deref(), Some("relay ok"));
|
||||
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false });
|
||||
assert_eq!(f.host.info_of("api14").unwrap().net_hosts, Vec::<String>::new());
|
||||
assert_eq!(seen.lock().len(), 1);
|
||||
|
||||
// The next request asks again; "Always allow" remembers the host.
|
||||
let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&["always"]));
|
||||
assert_eq!(result.unwrap().as_deref(), Some("relay ok"));
|
||||
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false });
|
||||
assert_eq!(f.host.info_of("api14").unwrap().net_hosts, ["127.0.0.1".to_owned()]);
|
||||
|
||||
// Remembered: connecting without a dialog from a window and in the background.
|
||||
let result = f.host.run_command("api14", "fetch", Some(&url), f.inv()).map_err(|e| e.message).unwrap();
|
||||
assert_eq!(result.as_deref(), Some("relay ok"));
|
||||
let result =
|
||||
f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).map_err(|e| e.message).unwrap();
|
||||
assert_eq!(result.as_deref(), Some("relay ok"));
|
||||
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
assert_eq!(seen.lock().len(), 4);
|
||||
|
||||
// The permissions list the host; revoking it asks again, and refusing denies.
|
||||
f.host.revoke_net_host("api14", "127.0.0.1").unwrap();
|
||||
assert_eq!(f.host.info_of("api14").unwrap().net_hosts, Vec::<String>::new());
|
||||
let (result, prompt) = net_ask_run_and_answer(&f, &url, None);
|
||||
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false });
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.contains("not granted"), "{err}");
|
||||
// Refusal throttles even a different host, without making any connection.
|
||||
let err = f.run("api14", "fetch", Some("https://other.example/")).unwrap_err();
|
||||
assert!(err.contains("not granted"), "{err}");
|
||||
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
assert_eq!(seen.lock().len(), 4);
|
||||
|
||||
f.host.uninstall("api14").unwrap();
|
||||
let ledger = ide_extension::ledger::Ledger::load(&f.plugins_dir);
|
||||
assert!(ledger.get(&ide_extension::ledger::Subject::Installed("api14".into())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_ask_session_hosts_are_ephemeral_and_cleared_with_plugin_grants() {
|
||||
use ide_extension::ledger::{Ledger, Subject};
|
||||
use ide_plugin::Prompt;
|
||||
let f = fixture(Limits::default());
|
||||
f.host.set_net_hosts_session_only();
|
||||
f.host.install_from_dir(&sample("api14"), &Consent::all()).unwrap();
|
||||
let (port, seen) = http_server("session ok", |_| Duration::ZERO);
|
||||
let url = format!("http://127.0.0.1:{port}/");
|
||||
let subject = Subject::Installed("api14".into());
|
||||
|
||||
for (answer, reset) in [("session", "disable"), ("always", "revoke"), ("session", "uninstall")] {
|
||||
let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err();
|
||||
assert!(err.message.contains("not granted"), "{err:?}");
|
||||
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
|
||||
let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&[answer]));
|
||||
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: true });
|
||||
assert_eq!(result.unwrap().as_deref(), Some("session ok"));
|
||||
for invocation in [f.inv(), Invocation::default()] {
|
||||
let result = f.host.run_command("api14", "fetch", Some(&url), invocation).unwrap();
|
||||
assert_eq!(result.as_deref(), Some("session ok"));
|
||||
}
|
||||
let err =
|
||||
f.host.run_command("api14", "fetch", Some("https://other.example/"), Invocation::default()).unwrap_err();
|
||||
assert!(err.message.contains("not granted"), "{err:?}");
|
||||
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
assert!(f.host.info_of("api14").unwrap().net_hosts.is_empty());
|
||||
assert!(Ledger::load(&f.plugins_dir).get(&subject).unwrap().net_hosts.is_empty());
|
||||
|
||||
match reset {
|
||||
"disable" => {
|
||||
f.host.set_enabled("api14", false).unwrap();
|
||||
f.host.set_enabled("api14", true).unwrap();
|
||||
}
|
||||
"revoke" => {
|
||||
f.host.set_capability("api14", "net:ask", false).unwrap();
|
||||
f.host.set_capability("api14", "net:ask", true).unwrap();
|
||||
}
|
||||
"uninstall" => {
|
||||
f.host.uninstall("api14").unwrap();
|
||||
assert!(Ledger::load(&f.plugins_dir).get(&subject).is_none());
|
||||
f.host.install_from_dir(&sample("api14"), &Consent::all()).unwrap();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err();
|
||||
assert!(err.message.contains("not granted"), "{err:?}");
|
||||
assert_eq!(seen.lock().len(), 9);
|
||||
}
|
||||
|
||||
/// Runs api14's `fetch` in its window; once the `net:ask` question arrives,
|
||||
/// answers it (`None`: dismisses). Returns the command's result and the prompt.
|
||||
fn net_ask_run_and_answer(
|
||||
f: &Fixture,
|
||||
url: &str,
|
||||
answer: Option<&[&str]>,
|
||||
) -> (Result<Option<String>, String>, ide_plugin::Prompt) {
|
||||
std::thread::scope(|scope| {
|
||||
let running = scope.spawn(|| f.run("api14", "fetch", Some(url)));
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let (client, prompt_id, prompt) = loop {
|
||||
let asked = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(asked) = asked {
|
||||
break asked;
|
||||
}
|
||||
assert!(Instant::now() < deadline && !running.is_finished(), "the fetch never asked");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
assert_eq!(client.as_deref(), Some("main"), "asks in the window that ran the command");
|
||||
let answer = answer.map(|a| a.iter().map(|s| (*s).to_owned()).collect());
|
||||
assert!(f.host.answer_prompt(&prompt_id, answer));
|
||||
assert!(!f.host.answer_prompt(&prompt_id, None), "an answer counts once");
|
||||
(running.join().unwrap(), prompt)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_window_status_overrides_fall_back_and_are_withdrawn() {
|
||||
let f = fixture(Limits::default());
|
||||
|
||||
@@ -616,16 +616,16 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
|
||||
assert_eq!(settings["run"]["defaultTarget"], "");
|
||||
let config = json!({"id":"wrapped-php", "name":"Wrapped PHP", "kind":"php", "command":"php", "args":["x.php", "literal ; $(touch bad)"], "env":{}, "detected":false, "isTest":false, "target":id});
|
||||
invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":38}})).await.unwrap();
|
||||
until("remote wrapped run", 5, || project.join("wrapped.txt").exists()).await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(project.join("wrapped.txt")).unwrap(),
|
||||
"<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n"
|
||||
);
|
||||
let wrapped = "<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n";
|
||||
until("remote wrapped run", 5, || std::fs::read_to_string(project.join("wrapped.txt")).is_ok_and(|s| s == wrapped))
|
||||
.await;
|
||||
assert!(!project.join("bad").exists());
|
||||
let composer = json!({"id":"composer", "name":"Composer", "kind":"shell", "command":"composer", "args":["install"], "env":{}, "detected":false, "isTest":false, "target":id});
|
||||
invoke(&peer, "run_start", json!({"config":composer, "onOutput":{"$channel":39}})).await.unwrap();
|
||||
until("remote composer override", 5, || project.join("composer.txt").exists()).await;
|
||||
assert_eq!(std::fs::read_to_string(project.join("composer.txt")).unwrap(), "<composer>\n<install>\n");
|
||||
until("remote composer override", 5, || {
|
||||
std::fs::read_to_string(project.join("composer.txt")).is_ok_and(|s| s == "<composer>\n<install>\n")
|
||||
})
|
||||
.await;
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
|
||||
@@ -20,7 +20,10 @@ pub use runtime::{
|
||||
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
|
||||
component_api, host_api,
|
||||
};
|
||||
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
|
||||
pub use sandbox::{
|
||||
MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real,
|
||||
resolve_writable_in_root,
|
||||
};
|
||||
pub use types::*;
|
||||
pub use wasmtime::component::Component;
|
||||
|
||||
|
||||
@@ -419,6 +419,21 @@ pub trait HostApi: Send + Sync {
|
||||
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError>;
|
||||
/// Performs `request` (its host is granted), filling in secret placeholders.
|
||||
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
|
||||
/// The `net:ask` answer for a request to HTTPS host `host` of this plugin
|
||||
/// (`subject`): `Some(true)` allowed and remembered (a host the user
|
||||
/// already allowed connects without asking again, also in the background),
|
||||
/// `Some(false)` allowed for this request only, `None` refused — also when
|
||||
/// nobody could be asked (`client` is `None` outside commands and native view actions).
|
||||
fn net_ask(
|
||||
&self,
|
||||
subject: &ide_extension::ledger::Subject,
|
||||
client: Option<&str>,
|
||||
host: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<bool>, HostError> {
|
||||
let _ = (subject, client, host, cancel);
|
||||
Ok(None)
|
||||
}
|
||||
fn secret_is_set(&self, name: &str) -> bool;
|
||||
|
||||
/// Stores or deletes a secret declared by this plugin; never returns its value.
|
||||
@@ -796,10 +811,12 @@ pub struct StoreData {
|
||||
stderr_seen: usize,
|
||||
pub(crate) plugin_id: String,
|
||||
pub(crate) granted: Vec<Capability>,
|
||||
pub(crate) subject: ide_extension::ledger::Subject,
|
||||
pub(crate) host: Arc<dyn HostApi>,
|
||||
pub(crate) invocation: Invocation,
|
||||
/// The running call is a command or an event.
|
||||
pub(crate) interactive: bool,
|
||||
pub(crate) net_interactive: bool,
|
||||
/// Editor window for pick, pick-many, input and confirm during action resolution.
|
||||
pub(crate) action_prompt_client: Option<String>,
|
||||
/// Tasks begun by the running call and not ended yet.
|
||||
@@ -913,6 +930,9 @@ pub struct RuntimeSetup {
|
||||
pub version: String,
|
||||
/// Capabilities granted to the plugin.
|
||||
pub granted: Vec<Capability>,
|
||||
/// Whose grant this is (installed id, or the package directory of a
|
||||
/// linked or project plugin): what `net:ask` answers are remembered for.
|
||||
pub subject: ide_extension::ledger::Subject,
|
||||
pub host: Arc<dyn HostApi>,
|
||||
pub limits: Limits,
|
||||
}
|
||||
@@ -1022,9 +1042,11 @@ impl Runtime {
|
||||
stderr_seen: 0,
|
||||
plugin_id: setup.plugin_id,
|
||||
granted: setup.granted,
|
||||
subject: setup.subject,
|
||||
host: setup.host,
|
||||
invocation: Invocation::default(),
|
||||
interactive: false,
|
||||
net_interactive: false,
|
||||
action_prompt_client: None,
|
||||
open_tasks: Vec::new(),
|
||||
last_report: None,
|
||||
@@ -1637,6 +1659,7 @@ impl Runtime {
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
store.data_mut().interactive = true;
|
||||
store.data_mut().net_interactive = true;
|
||||
let r = e.plugin.openidea_plugin_commands().call_run(
|
||||
&mut *store,
|
||||
id,
|
||||
@@ -1762,10 +1785,12 @@ impl Runtime {
|
||||
if self.exports.views.is_none() {
|
||||
return Err(CallError::missing_export("view-provider"));
|
||||
}
|
||||
let net_interactive = !matches!(&event, ViewEvent::Message { .. });
|
||||
let event = event_to_wit_view(event);
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
store.data_mut().interactive = true;
|
||||
store.data_mut().net_interactive = net_interactive;
|
||||
let views = e.views.as_ref().expect("checked");
|
||||
let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
@@ -1817,6 +1842,7 @@ impl Runtime {
|
||||
let data = self.store.data_mut();
|
||||
data.invocation = Invocation::default();
|
||||
data.interactive = false;
|
||||
data.net_interactive = false;
|
||||
data.action_prompt_client = None;
|
||||
// Tasks don't outlive the call that began them.
|
||||
for id in std::mem::take(&mut data.open_tasks) {
|
||||
|
||||
@@ -117,6 +117,45 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Resolves a content write's destination, including existing and dangling
|
||||
/// symlinks in the final component. Entry operations use [`resolve_new_in_root`].
|
||||
pub fn resolve_writable_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let (root, joined) = joined_path(root, path)?;
|
||||
let real = resolve_write_target(&joined, 40).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(format!("{path}: access outside the project root is not allowed"));
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
fn resolve_write_target(path: &Path, links_left: usize) -> Result<PathBuf, std::io::Error> {
|
||||
match dunce::canonicalize(path) {
|
||||
Ok(real) => return Ok(real),
|
||||
Err(error) => match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.is_symlink() => {
|
||||
if links_left == 0 {
|
||||
return Err(std::io::Error::other("too many symbolic links"));
|
||||
}
|
||||
let target = std::fs::read_link(path)?;
|
||||
let target = if target.is_absolute() { target } else { path.parent().unwrap().join(target) };
|
||||
return resolve_write_target(&target, links_left - 1);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
_ => return Err(error),
|
||||
},
|
||||
}
|
||||
let parent = path.parent().ok_or_else(|| std::io::Error::other("no directory to put it in"))?;
|
||||
let mut real = resolve_write_target(parent, links_left)?;
|
||||
match path.components().next_back() {
|
||||
Some(std::path::Component::ParentDir) => {
|
||||
real.pop();
|
||||
}
|
||||
Some(std::path::Component::Normal(name)) => real.push(name),
|
||||
_ => {}
|
||||
}
|
||||
if real != path { resolve_write_target(&real, links_left) } else { Ok(real) }
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 file that `resolve_in_root` already approved.
|
||||
pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
@@ -194,9 +233,9 @@ pub fn resolve_granted(granted: &[Capability], path: &str) -> Result<PathBuf, Re
|
||||
/// The real path `path` would be written to, when an `fs.write:<path>` grant
|
||||
/// covers it.
|
||||
///
|
||||
/// A file that does not exist yet cannot be canonicalized, so its directory is
|
||||
/// canonicalized instead and the name appended; an existing file (or link) is
|
||||
/// followed first, so a link cannot be used to write outside the grant.
|
||||
/// Missing paths and dangling links are resolved to their target location;
|
||||
/// an existing file (or link) is followed first. The destination's directory
|
||||
/// must exist.
|
||||
pub fn resolve_writable(granted: &[Capability], path: &str) -> Result<PathBuf, Refusal> {
|
||||
resolve(granted, path, Wanted::Write)
|
||||
}
|
||||
@@ -241,12 +280,14 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
|
||||
if covered { Ok(real) } else { Err(Refusal::Denied(cap.to_string())) }
|
||||
}
|
||||
|
||||
/// Where `asked` would be created: its directory, resolved, plus its name.
|
||||
/// Where `asked` would be created, including a dangling link's target.
|
||||
fn resolve_unsaved(asked: &Path) -> Result<PathBuf, String> {
|
||||
let name = asked.file_name().ok_or_else(|| "not a file name".to_string())?;
|
||||
let parent = asked.parent().ok_or_else(|| "no directory to put it in".to_string())?;
|
||||
let dir = dunce::canonicalize(parent).map_err(|_| "its directory does not exist".to_string())?;
|
||||
Ok(dir.join(name))
|
||||
let real = resolve_write_target(asked, 40).map_err(|e| e.to_string())?;
|
||||
let parent = real.parent().ok_or_else(|| "no directory to put it in".to_string())?;
|
||||
if !parent.is_dir() {
|
||||
return Err("its directory does not exist".into());
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Simplifies Windows drive paths only when the plain form preserves their meaning.
|
||||
@@ -387,6 +428,82 @@ mod tests {
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_paths_follow_existing_and_dangling_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
std::fs::write(root.join("inside.txt"), "before").unwrap();
|
||||
for (name, target) in [
|
||||
("outside", outside.clone()),
|
||||
("dangling-outside", tmp.path().join("missing.txt")),
|
||||
("inside", PathBuf::from("inside.txt")),
|
||||
("dangling-inside", PathBuf::from("new/deep.txt")),
|
||||
("chain", PathBuf::from("dangling-outside")),
|
||||
("cycle", PathBuf::from("cycle")),
|
||||
] {
|
||||
symlink(target, root.join(name)).unwrap();
|
||||
}
|
||||
for name in ["outside", "dangling-outside", "chain"] {
|
||||
assert!(resolve_writable_in_root(&root, name).unwrap_err().contains("outside the project root"));
|
||||
assert!(
|
||||
resolve_writable_in_root(&root, &format!("missing/../{name}"))
|
||||
.unwrap_err()
|
||||
.contains("outside the project root")
|
||||
);
|
||||
}
|
||||
assert!(resolve_writable_in_root(&root, "cycle").is_err());
|
||||
let inside = resolve_writable_in_root(&root, "inside").unwrap();
|
||||
assert_eq!(inside, root.join("inside.txt"));
|
||||
std::fs::write(inside, "after").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(root.join("inside.txt")).unwrap(), "after");
|
||||
assert_eq!(resolve_writable_in_root(&root, "dangling-inside").unwrap(), root.join("new/deep.txt"));
|
||||
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
|
||||
assert!(!tmp.path().join("missing.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_grants_confine_dangling_symlink_targets() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("granted");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let grant = Capability::FsWrite(strip_verbatim(&root));
|
||||
symlink("../missing.txt", root.join("outside")).unwrap();
|
||||
symlink("new.txt", root.join("inside")).unwrap();
|
||||
assert!(matches!(
|
||||
resolve_writable(std::slice::from_ref(&grant), root.join("outside").to_str().unwrap()),
|
||||
Err(Refusal::Denied(_))
|
||||
));
|
||||
assert_eq!(resolve_writable(&[grant], root.join("inside").to_str().unwrap()).unwrap(), root.join("new.txt"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn entry_operations_do_not_follow_outside_symlinks() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
|
||||
let from = resolve_new_in_root(&root, "link").unwrap();
|
||||
let to = resolve_new_in_root(&root, "renamed").unwrap();
|
||||
std::fs::rename(&from, &to).unwrap();
|
||||
assert!(!from.try_exists().unwrap());
|
||||
assert!(std::fs::symlink_metadata(&to).unwrap().is_symlink());
|
||||
std::fs::remove_file(resolve_new_in_root(&root, "renamed").unwrap()).unwrap();
|
||||
assert!(std::fs::symlink_metadata(&to).is_err());
|
||||
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_cannot_escape() {
|
||||
|
||||
@@ -94,6 +94,10 @@ pub enum Prompt {
|
||||
Confirm { title: String, message: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PickDirectory { title: String, initial: Option<String> },
|
||||
/// The host's own question under `net:ask` (never plugin-initiated): the
|
||||
/// plugin reached an HTTPS host its manifest does not list.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
NetPermission { host: String, session_only: bool },
|
||||
}
|
||||
|
||||
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
|
||||
@@ -437,7 +441,13 @@ pub fn net_host(url: &str) -> Result<String, String> {
|
||||
return Err(format!("unsupported scheme {:?}", parsed.scheme()));
|
||||
}
|
||||
match parsed.host() {
|
||||
Some(url::Host::Domain(host)) => Ok(host.trim_end_matches('.').to_ascii_lowercase()),
|
||||
Some(url::Host::Domain(host)) => {
|
||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||
if host.is_empty() || host.contains('*') {
|
||||
return Err("URL must name one exact host".into());
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
Some(url::Host::Ipv4(host)) => Ok(host.to_string()),
|
||||
Some(url::Host::Ipv6(host)) => Ok(host.to_string()),
|
||||
None => Err("URL has no host".into()),
|
||||
@@ -479,8 +489,21 @@ mod tests {
|
||||
net_capability("http://example.com/").unwrap(),
|
||||
ide_extension::Capability::NetHttp("example.com".into())
|
||||
);
|
||||
for (url, host) in [
|
||||
("https://EXAMPLE.com.:8443/", "example.com"),
|
||||
("https://bücher.example/", "xn--bcher-kva.example"),
|
||||
("https://xn--bcher-kva.example/", "xn--bcher-kva.example"),
|
||||
("https://127.1/", "127.0.0.1"),
|
||||
("https://[0:0:0:0:0:0:0:1]:8443/", "::1"),
|
||||
("https://sub.example.com/", "sub.example.com"),
|
||||
] {
|
||||
assert_eq!(net_host(url).unwrap(), host);
|
||||
}
|
||||
assert_ne!(net_host("https://аpi.example/").unwrap(), net_host("https://api.example/").unwrap());
|
||||
assert!(net_host("file:///etc/passwd").is_err());
|
||||
assert!(net_host("example.com").is_err());
|
||||
assert!(net_host("https://*.example.com/").is_err());
|
||||
assert!(net_host("https://./").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -322,6 +322,23 @@ impl StoreData {
|
||||
answer.map_err(to_wit)
|
||||
}
|
||||
|
||||
/// The user's answer for a request to `host` under `net:ask`:
|
||||
/// `Some(_)` allows it (`true`: the host is remembered). `None` when
|
||||
/// nobody could be asked or the user refused. Commands and view actions pass
|
||||
/// their window; background and scheduled calls ask nobody, so only an
|
||||
/// already-allowed host connects there.
|
||||
fn ask_net(&mut self, host: &str) -> Result<Option<bool>, wit::Error> {
|
||||
let client = self.net_interactive.then(|| self.invocation.client.clone()).flatten();
|
||||
let subject = self.subject.clone();
|
||||
let started = Instant::now();
|
||||
let answer = self.host.net_ask(&subject, client.as_deref(), host, &self.cancel.token());
|
||||
// The wait doesn't count against the call's wall-clock budget.
|
||||
let waited = started.elapsed();
|
||||
self.deadline += waited;
|
||||
self.call_started += waited;
|
||||
answer.map_err(to_wit)
|
||||
}
|
||||
|
||||
fn pick_prompt(
|
||||
&mut self,
|
||||
title: String,
|
||||
@@ -761,7 +778,7 @@ impl workspace::HostProject for StoreData {
|
||||
return Ok(Err(too_much_to_write(&path)));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_new_in_root(&root, &path) {
|
||||
let real = match sandbox::resolve_writable_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
@@ -1269,8 +1286,26 @@ impl net::Host for StoreData {
|
||||
Ok(h) => h,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
if let Err(e) = self.require(capability) {
|
||||
return Ok(Err(e));
|
||||
let mut grants = self.granted.clone();
|
||||
if let Err(e) = self.require(capability.clone()) {
|
||||
// `net:ask`: an HTTPS (or loopback) host the manifest does not list
|
||||
// may ask the user — commands and view actions from a window only. Plain
|
||||
// HTTP elsewhere always needs `net.http:<host>`.
|
||||
let askable =
|
||||
matches!(&capability, Capability::Net(_)) && self.granted.iter().any(|g| g.covers(&Capability::NetAsk));
|
||||
let allowed = match &capability {
|
||||
Capability::Net(host) if askable => match self.ask_net(host) {
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => false,
|
||||
// A cancelled or failed question is not a refusal.
|
||||
Err(e) => return Ok(Err(e)),
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
if !allowed {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
grants.push(capability.clone());
|
||||
}
|
||||
if req.headers.iter().any(|h| h.value.contains("{{secret:"))
|
||||
&& let Err(e) = self.require(Capability::Secrets)
|
||||
@@ -1287,7 +1322,7 @@ impl net::Host for StoreData {
|
||||
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
|
||||
body: req.body,
|
||||
timeout: wanted.min(self.remaining()),
|
||||
grants: self.granted.clone(),
|
||||
grants,
|
||||
};
|
||||
let token = self.cancel.token();
|
||||
Ok(self
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
023542d758cd359aa4d1282b95ddd2edca9fcf746862700705dd637b3e524571
|
||||
daecb28adad6f82f932ef70e042f520959d3429e9e2a23cda7df7ec05ba5f259
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
id = "api14"
|
||||
name = "API 1.4 host tests"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.4"
|
||||
engines.openidea = "^1.5"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*", "on-event:file-closed", "on-event:settings-changed", "on-event:input-mode-changed"]
|
||||
|
||||
[capabilities]
|
||||
required = ["storage"]
|
||||
optional = ["ide.commands", "project.read", "vcs.read", "events", "events.input", "editor.read", "secrets", "net:localhost", "net.http:example.com", "ipc:$TMPDIR/api14-*"]
|
||||
optional = ["ide.commands", "project.read", "vcs.read", "events", "events.input", "editor.read", "secrets", "net:localhost", "net.http:example.com", "net:ask", "ipc:$TMPDIR/api14-*"]
|
||||
|
||||
[[contributes.secrets]]
|
||||
name = "token"
|
||||
|
||||
@@ -98,7 +98,7 @@ fn jar_applications_from_every_configuration_location() {
|
||||
"-jar",
|
||||
"../dist/server app.jar",
|
||||
"--config",
|
||||
&format!("{root}/server config.yml"),
|
||||
&format!("{}/server config.yml", dir.path().to_string_lossy()),
|
||||
""
|
||||
])
|
||||
);
|
||||
|
||||
+188
-1
@@ -131,6 +131,117 @@ fn delve_build_flags(args: &[String], files: bool) -> Value {
|
||||
)
|
||||
}
|
||||
|
||||
/// An explicitly selected Go executable must also be the compiler Delve finds on PATH.
|
||||
pub fn prepare_debug_compiler(
|
||||
env: &mut std::collections::BTreeMap<String, String>,
|
||||
extra_json: &Option<String>,
|
||||
) -> ide_core::IdeResult<()> {
|
||||
#[cfg(windows)]
|
||||
for key in ["PATH", "GOROOT"] {
|
||||
if let Some(previous) = env.keys().find(|name| name.eq_ignore_ascii_case(key) && name.as_str() != key).cloned()
|
||||
{
|
||||
let value = env.remove(&previous).unwrap();
|
||||
env.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
let extra: Value = extra_json.as_deref().and_then(|value| serde_json::from_str(value).ok()).unwrap_or_default();
|
||||
let Some(executable) = extra["__goExecutable"].as_str().filter(|value| *value != "go" && *value != "go.exe") else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(crate::exec::search_path);
|
||||
let executable = ide_core::process::find_executable(executable, &path)
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable(format!("Go executable not found: {executable}")))?;
|
||||
if !executable.file_name().is_some_and(|name| {
|
||||
if cfg!(windows) {
|
||||
let name = name.to_string_lossy();
|
||||
name.eq_ignore_ascii_case("go") || name.eq_ignore_ascii_case("go.exe")
|
||||
} else {
|
||||
name == "go"
|
||||
}
|
||||
}) {
|
||||
return Err(ide_core::IdeError::invalid("Delve requires a Go SDK executable named go or go.exe"));
|
||||
}
|
||||
if !env.contains_key("GOROOT") {
|
||||
let mut command = std::process::Command::new(&executable);
|
||||
command.args(["env", "GOROOT"]).envs(env.iter());
|
||||
let goroot = crate::exec::query_command(command, std::time::Duration::from_secs(10))
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("Cannot query the selected Go SDK"))?;
|
||||
env.insert("GOROOT".into(), goroot);
|
||||
}
|
||||
let mut paths = vec![executable.parent().unwrap().to_path_buf()];
|
||||
paths.extend(std::env::split_paths(&path));
|
||||
let path = std::env::join_paths(paths).map_err(|error| ide_core::IdeError::invalid(error.to_string()))?;
|
||||
env.insert("PATH".into(), path.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_debug_target(
|
||||
program: &mut Option<String>,
|
||||
env: &std::collections::BTreeMap<String, String>,
|
||||
extra: &mut Value,
|
||||
flags: &mut Vec<String>,
|
||||
) -> ide_core::IdeResult<()> {
|
||||
let Some(targets) = extra.get("__goTargets") else { return Ok(()) };
|
||||
let targets: Vec<String> =
|
||||
serde_json::from_value(targets.clone()).map_err(|error| ide_core::IdeError::invalid(error.to_string()))?;
|
||||
let dir = extra["__goBuildDir"]
|
||||
.as_str()
|
||||
.map(std::path::Path::new)
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("Go debug build directory is missing"))?;
|
||||
if targets.iter().any(|target| target.ends_with(".go")) {
|
||||
if !targets.iter().all(|target| target.ends_with(".go")) {
|
||||
return Err(ide_core::IdeError::invalid("Select Go source files or one package for debugging"));
|
||||
}
|
||||
let files: Vec<_> =
|
||||
targets.iter().map(|target| dunce::canonicalize(dir.join(target))).collect::<Result<_, _>>()?;
|
||||
let first = files.first().ok_or_else(|| ide_core::IdeError::invalid("Go debug target is missing"))?;
|
||||
if files.iter().any(|file| file.parent() != first.parent()) {
|
||||
return Err(ide_core::IdeError::invalid("Go source files must be in the same directory"));
|
||||
}
|
||||
*program = Some(first.to_string_lossy().into_owned());
|
||||
flags.extend(files.iter().skip(1).map(|file| file.to_string_lossy().into_owned()));
|
||||
extra["__goSourceArgs"] = json!(true);
|
||||
return Ok(());
|
||||
}
|
||||
let path = env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(crate::exec::search_path);
|
||||
let go = ide_core::process::find_executable("go", &path)
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("Go executable not found"))?;
|
||||
let mut command = std::process::Command::new(go);
|
||||
command.args(["list", "-find", "-f", "{{.Dir}}"]).current_dir(dir).envs(env.iter());
|
||||
let mut args = flags.iter();
|
||||
while let Some(flag) = args.next() {
|
||||
let name = flag.split('=').next().unwrap_or(flag);
|
||||
if ["-tags", "-mod", "-modfile", "-overlay", "-compiler"].contains(&name) {
|
||||
command.arg(flag);
|
||||
if name == flag
|
||||
&& let Some(value) = args.next()
|
||||
{
|
||||
command.arg(value);
|
||||
}
|
||||
} else if ["-race", "-msan", "-asan"].contains(&name) {
|
||||
command.arg(flag);
|
||||
}
|
||||
}
|
||||
command.args(&targets);
|
||||
let output = crate::exec::query_command(command, std::time::Duration::from_secs(10)).ok_or_else(|| {
|
||||
ide_core::IdeError::invalid(format!(
|
||||
"Cannot resolve Go debug target: {}. Check its module path and build flags.",
|
||||
targets.join(" ")
|
||||
))
|
||||
})?;
|
||||
let mut dirs: Vec<_> = output.lines().filter(|line| !line.trim().is_empty()).collect();
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
if dirs.len() != 1 {
|
||||
return Err(ide_core::IdeError::invalid(format!(
|
||||
"Go debugging requires one package; this target selects {}. Select a package or test instead.",
|
||||
dirs.len()
|
||||
)));
|
||||
}
|
||||
*program = Some(dirs[0].to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_to_debug(
|
||||
env: &mut std::collections::BTreeMap<String, String>,
|
||||
program: &mut Option<String>,
|
||||
@@ -170,6 +281,7 @@ pub fn apply_to_debug(
|
||||
.collect::<Vec<_>>();
|
||||
merge_flags(&mut args, &flags);
|
||||
}
|
||||
resolve_debug_target(program, env, &mut extra, &mut args)?;
|
||||
if let Some(files) = extra.get("__goFiles").and_then(Value::as_array) {
|
||||
if extra["mode"] == "test" {
|
||||
if let Some(file) = files.first().and_then(Value::as_str).map(std::path::Path::new) {
|
||||
@@ -190,7 +302,10 @@ pub fn apply_to_debug(
|
||||
}
|
||||
// Delve appends its -o after array build flags; positional source files
|
||||
// require the string form so all compiler flags precede the file list.
|
||||
extra["buildFlags"] = delve_build_flags(&args, extra.get("__goFiles").is_some() && extra["mode"] != "test");
|
||||
extra["buildFlags"] = delve_build_flags(
|
||||
&args,
|
||||
(extra.get("__goFiles").is_some() && extra["mode"] != "test") || extra["__goSourceArgs"] == true,
|
||||
);
|
||||
}
|
||||
if let Some(output) = extra.get("output").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).parent()) {
|
||||
std::fs::create_dir_all(output)?;
|
||||
@@ -199,6 +314,10 @@ pub fn apply_to_debug(
|
||||
map.remove("__goUseTags");
|
||||
map.remove("__goToolArgs");
|
||||
map.remove("__goFiles");
|
||||
map.remove("__goExecutable");
|
||||
map.remove("__goTargets");
|
||||
map.remove("__goBuildDir");
|
||||
map.remove("__goSourceArgs");
|
||||
}
|
||||
*extra_json = Some(extra.to_string());
|
||||
Ok(())
|
||||
@@ -209,6 +328,74 @@ mod command_tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn debug_targets_resolve_imports_and_reject_multiple_packages() {
|
||||
if crate::exec::which("go").is_none() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("go.mod"), "module example.com/debugtargets\ngo 1.22\n").unwrap();
|
||||
for name in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(name)).unwrap();
|
||||
std::fs::write(root.join(name).join("lib.go"), "package p\nfunc Value() {}\n").unwrap();
|
||||
}
|
||||
let mut env = BTreeMap::from([("GOWORK".into(), "off".into())]);
|
||||
let launch = |target: &str| {
|
||||
Some(json!({"mode":"test", "__goToolArgs":[], "__goTargets":[target], "__goBuildDir":root}).to_string())
|
||||
};
|
||||
let mut program = None;
|
||||
let mut extra = launch("example.com/debugtargets/a");
|
||||
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut extra, &GoBuildTags::default()).unwrap();
|
||||
assert_eq!(program.as_deref(), root.join("a").to_str());
|
||||
let value: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
|
||||
assert!(value.get("__goTargets").is_none());
|
||||
let error =
|
||||
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut launch("./..."), &GoBuildTags::default())
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("requires one package"), "{error}");
|
||||
std::fs::write(root.join("main.go"), "package main\nfunc main(){helper()}\n").unwrap();
|
||||
std::fs::write(root.join("helper.go"), "package main\nfunc helper(){}\n").unwrap();
|
||||
let mut extra = Some(
|
||||
json!({"mode":"debug","__goBuildDir":root,"__goTargets":["main.go","helper.go"],"__goToolArgs":[]})
|
||||
.to_string(),
|
||||
);
|
||||
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut extra, &GoBuildTags::default()).unwrap();
|
||||
assert_eq!(program.as_deref(), root.join("main.go").to_str());
|
||||
let value: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
value["buildFlags"],
|
||||
format!("'{}'", root.join("helper.go").to_string_lossy().replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn compiler_selection_preserves_case_insensitive_windows_environment_keys() {
|
||||
let mut env =
|
||||
BTreeMap::from([("Path".into(), "explicit path".into()), ("GoRoot".into(), "explicit root".into())]);
|
||||
prepare_debug_compiler(&mut env, &None).unwrap();
|
||||
assert_eq!(env["PATH"], "explicit path");
|
||||
assert_eq!(env["GOROOT"], "explicit root");
|
||||
assert!(!env.contains_key("Path"));
|
||||
assert!(!env.contains_key("GoRoot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selected_go_compiler_precedes_the_existing_path() {
|
||||
let Some(go) = crate::exec::which("go") else {
|
||||
return;
|
||||
};
|
||||
let mut env = BTreeMap::from([("PATH".into(), "/unrelated".into())]);
|
||||
let extra = Some(json!({"__goExecutable":go}).to_string());
|
||||
prepare_debug_compiler(&mut env, &extra).unwrap();
|
||||
assert_eq!(std::env::split_paths(&env["PATH"]).next().as_deref(), go.parent());
|
||||
assert!(!env["GOROOT"].is_empty());
|
||||
env.insert("GOROOT".into(), "explicit".into());
|
||||
prepare_debug_compiler(&mut env, &extra).unwrap();
|
||||
assert_eq!(env["GOROOT"], "explicit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_go_runs_inherit_tags_only_for_commands_that_accept_build_flags() {
|
||||
let tags = GoBuildTags { custom: "integration".into(), ..Default::default() };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! `openidea-server` builds for remote development: the release of this
|
||||
//! version publishes one per supported host platform (see `release.yml`).
|
||||
//! version publishes one per supported host platform (see `release-build.yml`).
|
||||
|
||||
use crate::process::Http;
|
||||
use crate::{InstallEvent, fetch_verified, sources};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [140, 320]) {
|
||||
test(`test runner keeps status columns aligned in ${theme} at ${width}px`, async ({ workbench: page }) => {
|
||||
const id = await page.evaluate(async (theme) => {
|
||||
const storePath = "/src/features/run/store.ts";
|
||||
const resultsPath = "/src/features/run/testing/index.ts";
|
||||
const themePath = "/src/workbench/theme.ts";
|
||||
const run = await import(storePath);
|
||||
const { TestResults } = await import(resultsPath);
|
||||
const { previewTheme } = await import(themePath);
|
||||
previewTheme(theme);
|
||||
localStorage.setItem("openidea.run.testFilters", JSON.stringify({ showPassed: true, showIgnored: true }));
|
||||
await run.startSampleOutput(0, 1);
|
||||
const id = run.useRun.getState().activeSession;
|
||||
const results = new TestResults();
|
||||
results.apply([
|
||||
{ type: "result", path: ["AlignmentSuite", "passed case"], status: "passed", durationMs: 12 },
|
||||
{ type: "result", path: ["AlignmentSuite", "failed case"], status: "failed", durationMs: 23 },
|
||||
{ type: "result", path: ["AlignmentSuite", "ignored case"], status: "skipped" },
|
||||
{ type: "start", path: ["AlignmentSuite", "running case"] },
|
||||
]);
|
||||
results.node(["AlignmentSuite", "pending case"]);
|
||||
run.runtime(id).tests = results;
|
||||
const state = run.useRun.getState();
|
||||
run.useRun.setState({
|
||||
sessions: state.sessions.map((s: { id: string; config: object }) =>
|
||||
s.id === id ? { ...s, status: "running", config: { ...s.config, isTest: true } } : s,
|
||||
),
|
||||
versions: { ...state.versions, [id]: (state.versions[id] ?? 0) + 1 },
|
||||
});
|
||||
return id;
|
||||
}, theme);
|
||||
|
||||
const run = page.getByRole("region", { name: "Run", exact: true });
|
||||
const panel = run.getByTestId("tests");
|
||||
const tree = panel.getByRole("tree", { name: "Test results" });
|
||||
await expect(tree.getByText("pending case", { exact: true })).toBeVisible();
|
||||
const bounds = (await panel.boundingBox())!;
|
||||
const handle = (await run.getByRole("separator").boundingBox())!;
|
||||
const y = handle.y + handle.height / 2;
|
||||
await page.mouse.move(handle.x + handle.width / 2, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + width, y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => Math.abs((await panel.boundingBox())!.width - width)).toBeLessThan(2);
|
||||
if (process.env.OPENIDEA_TEST_RUNNER_SHOTS)
|
||||
await panel.screenshot({ path: `${process.env.OPENIDEA_TEST_RUNNER_SHOTS}/${theme}-${width}.png` });
|
||||
|
||||
const cases = ["passed case", "failed case", "ignored case", "running case", "pending case"];
|
||||
const row = (name: string) => tree.getByRole("treeitem").filter({ hasText: name });
|
||||
const checkColumns = async (size: number) => {
|
||||
const starts: number[] = [];
|
||||
for (const name of cases) {
|
||||
const icon = row(name).locator('svg[role="img"], svg[role="status"]');
|
||||
const iconBounds = (await icon.boundingBox())!;
|
||||
const dimensions = await icon.evaluate((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };
|
||||
});
|
||||
expect.soft(dimensions.width).toBe(size);
|
||||
expect.soft(dimensions.height).toBe(size);
|
||||
const label = (await row(name).getByText(name, { exact: true }).boundingBox())!;
|
||||
const rowBounds = (await row(name).boundingBox())!;
|
||||
starts.push(label.x);
|
||||
expect
|
||||
.soft(Math.abs(iconBounds.y + iconBounds.height / 2 - (rowBounds.y + rowBounds.height / 2)))
|
||||
.toBeLessThan(1);
|
||||
}
|
||||
expect.soft(Math.max(...starts) - Math.min(...starts)).toBeLessThan(1);
|
||||
};
|
||||
await checkColumns(16);
|
||||
const paneBounds = (await panel.boundingBox())!;
|
||||
for (const title of ["Show Passed", "Show Ignored"]) {
|
||||
const button = panel.getByRole("button", { name: title, exact: true });
|
||||
const rect = (await button.boundingBox())!;
|
||||
expect(rect.x).toBeGreaterThanOrEqual(paneBounds.x);
|
||||
expect(rect.x + rect.width).toBeLessThanOrEqual(paneBounds.x + paneBounds.width + 1);
|
||||
expect.soft((await button.locator("svg").boundingBox())!.width).toBe(16);
|
||||
}
|
||||
const fills = await row("failed case")
|
||||
.getByRole("img", { name: "Failed", exact: true })
|
||||
.locator("[fill]")
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.filter((node) => node.getBoundingClientRect().width > 0).map((node) => getComputedStyle(node).fill),
|
||||
);
|
||||
expect.soft(fills).toContain(theme === "light" ? "rgb(255, 175, 15)" : "rgb(242, 197, 92)");
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/ui/iconAppearance.ts";
|
||||
(await import(path)).setIconAppearance({ iconSize: 20, rowHeight: 28 });
|
||||
});
|
||||
await checkColumns(20);
|
||||
const before = (await row("running case").getByText("running case", { exact: true }).boundingBox())!.x;
|
||||
await page.evaluate(async (id) => {
|
||||
const path = "/src/features/run/store.ts";
|
||||
const run = await import(path);
|
||||
run
|
||||
.runtime(id)
|
||||
.tests.apply([
|
||||
{ type: "result", path: ["AlignmentSuite", "running case"], status: "passed", durationMs: 34 },
|
||||
]);
|
||||
const state = run.useRun.getState();
|
||||
run.useRun.setState({ versions: { ...state.versions, [id]: (state.versions[id] ?? 0) + 1 } });
|
||||
}, id);
|
||||
await expect(row("running case").getByRole("img", { name: "Passed", exact: true })).toBeVisible();
|
||||
expect.soft((await row("running case").getByText("running case", { exact: true }).boundingBox())!.x).toBe(before);
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/ui/iconAppearance.ts";
|
||||
(await import(path)).setIconAppearance(null);
|
||||
});
|
||||
|
||||
const showPassed = panel.getByRole("button", { name: "Show Passed", exact: true });
|
||||
await showPassed.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(row("passed case")).toHaveCount(0);
|
||||
await expect(row("running case")).toHaveCount(0);
|
||||
await showPassed.click();
|
||||
await expect(row("passed case")).toBeVisible();
|
||||
const showIgnored = panel.getByRole("button", { name: "Show Ignored", exact: true });
|
||||
await showIgnored.click();
|
||||
await expect(row("ignored case")).toHaveCount(0);
|
||||
await showIgnored.click();
|
||||
await expect(row("ignored case")).toBeVisible();
|
||||
});
|
||||
}
|
||||
}
|
||||
+16
-10
@@ -13,6 +13,8 @@ something to show.
|
||||
| `go-app/` | Go 1.22+ | `go run ./cmd/server` | `go test ./...` | `OPENIDEA_FAIL=1 go test ./...` | unused `fmt.Sprintf` result in `internal/store/store.go` |
|
||||
| `kotlin-gradle/` | JDK 21, Gradle 8 (no wrapper) | `gradle run` | `gradle test` | `gradle test -PopenideaFail` | unused variable in `App.kt` |
|
||||
| `java-app/` | JDK 21, Maven 3.9 | `mvn compile exec:java` | `mvn test` | `mvn test -DopenideaFail=true` | unused variable in `App.java` |
|
||||
| `c-app/` | gcc / g++ 12+, make, GDB 14+ | `make all` | — | — | — |
|
||||
| `php-app/` | PHP 8.5, Composer | `composer quote` | `vendor/bin/pest` | `OPENIDEA_FAIL=1 vendor/bin/pest` | unused variable in `ThresholdCustoms.php` |
|
||||
| `polyglot/` | Go, TS, Python, C, shell, SQL, TOML/YAML/JSON, proto, Terraform | `make` | `make test` | — | unused variable in `native/hash.c` |
|
||||
|
||||
The fixtures are standalone: `rust-app` declares its own `[workspace]` and
|
||||
@@ -36,7 +38,8 @@ git init -q && git add -A && git commit -qm "initial" # for the VCS checks
|
||||
|
||||
Then open the folder with **File › Open…** (`⌘⇧⌥O`) or from the Welcome screen.
|
||||
For `ts-app` / `polyglot/web` run `pnpm install` first; for `python-app` create a
|
||||
venv (`python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'`).
|
||||
venv (`python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'`); for
|
||||
`php-app` run `composer install` (debugging it needs Xdebug in the PHP on `PATH`).
|
||||
|
||||
Missing language servers / debuggers can be installed from the IDE:
|
||||
**Settings › Languages & Frameworks › Language Servers**, or from the
|
||||
@@ -57,17 +60,18 @@ language detection and highlighting.
|
||||
### Project tree
|
||||
|
||||
- [ ] Arrow keys move the selection; `→`/`←` expand/collapse; `Enter`/`F4` opens a file.
|
||||
- [ ] Ignored folders (`target/`, `node_modules/`, `build/`) appear dimmed.
|
||||
- [ ] Ignored folders (`target/`, `node_modules/`, `build/`, `vendor/`) appear dimmed.
|
||||
- [ ] New file (`⌘N`), rename (`⇧F6`), delete (`⌫`) work and the tree updates.
|
||||
|
||||
### Search Everywhere & navigation
|
||||
|
||||
- [ ] Double `⇧` opens Search Everywhere; typing `parser` finds `src/parser.rs`.
|
||||
- [ ] `⌘O` (classes) finds `Inventory` / `Cart` / `TaskList` / `Library`.
|
||||
- [ ] `⌘O` (classes) finds `Inventory` / `Cart` / `TaskList` / `Library` /
|
||||
`Shipment`.
|
||||
- [ ] `⌘⇧O` (files) and `⌘⌥O` (symbols) find `format_price` / `formatMoney`.
|
||||
- [ ] `⌘E` lists recent files; `⌘[` / `⌘]` navigate back/forward.
|
||||
- [ ] `⌘B` on a call (`format_price` in `main.rs`, `cart.total()` in `index.ts`)
|
||||
jumps to the declaration; `⌥F7` lists usages.
|
||||
- [ ] `⌘B` on a call (`format_price` in `main.rs`, `cart.total()` in `index.ts`,
|
||||
`billableGrams` in `quote.php`) jumps to the declaration; `⌥F7` lists usages.
|
||||
- [ ] `⌘F12` shows the file structure; `⌘L` goes to a line.
|
||||
|
||||
### Editing & code insight
|
||||
@@ -75,7 +79,8 @@ language detection and highlighting.
|
||||
- [ ] Typing in a file marks the tab modified; `⌘S` saves; external edits reload.
|
||||
- [ ] `⌘F` opens the find bar (match case / words / regex); `⌘R` replaces.
|
||||
- [ ] `⌘⇧F` finds `TODO` across the project and opens results.
|
||||
- [ ] Completion (`⌃Space`) on `inventory.` / `cart.` / `tasks.` lists members.
|
||||
- [ ] Completion (`⌃Space`) on `inventory.` / `cart.` / `tasks.` / `$parcel->` lists
|
||||
members.
|
||||
- [ ] Hover / `F1` shows documentation; `⌘P` shows parameter info.
|
||||
- [ ] Warnings: the intentional warning is underlined and listed in Problems (`⌘6`);
|
||||
`F2` jumps to it; `⌥Enter` offers a quick fix.
|
||||
@@ -91,7 +96,8 @@ language detection and highlighting.
|
||||
### Run configurations & tests
|
||||
|
||||
- [ ] Run configurations are detected: cargo run/test (Rust), package.json scripts
|
||||
(TS), pytest (Python), `go test` / `go run` (Go), Gradle tasks (Kotlin).
|
||||
(TS), pytest (Python), `go test` / `go run` (Go), Gradle tasks (Kotlin),
|
||||
composer.json scripts and Pest (PHP).
|
||||
- [ ] Gutter run icons appear next to `main` and test functions; running one opens
|
||||
the Run tool window (`⌘4`) with the output.
|
||||
- [ ] The test tree shows passed tests; enabling the failing test (table above)
|
||||
@@ -103,10 +109,10 @@ language detection and highlighting.
|
||||
- [ ] Each fixture's entry point has a "Set a breakpoint here" comment: toggle a
|
||||
breakpoint there (`⌘F8`) and start debugging (`⌃D`).
|
||||
- [ ] Execution stops on the line; Frames and Variables show `inventory` / `cart` /
|
||||
`tasks` / `library`; inline values appear in the editor.
|
||||
`tasks` / `library` / `$shipment`; inline values appear in the editor.
|
||||
- [ ] Step over (`F8`), into (`F7`), out (`⇧F8`), resume (`⌘⌥R`) and evaluate (`⌥F8`).
|
||||
- [ ] If the debug adapter is missing (CodeLLDB, debugpy, Delve), the error offers
|
||||
the installer and debugging works after installing.
|
||||
- [ ] If the debug adapter is missing (CodeLLDB, debugpy, Delve, PHP Debug), the error
|
||||
offers the installer and debugging works after installing.
|
||||
|
||||
### Git
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Debugging the entry points with GDB, picked in the configuration's Debug adapter
|
||||
# control (explicit choice: the C/C++ default stays lldb-dap). GDB 14+ has the
|
||||
# DAP interpreter; `make all` builds `bin/` first.
|
||||
|
||||
[[configurations]]
|
||||
id = "user:app-c"
|
||||
name = "app-c (GDB)"
|
||||
kind = "shell"
|
||||
command = "bin/app-c"
|
||||
debug-adapter = "gdb"
|
||||
env = { OPENIDEA_GDB_FIXTURE = "present" }
|
||||
|
||||
[[configurations]]
|
||||
id = "user:app-cpp"
|
||||
name = "app-cpp (GDB)"
|
||||
kind = "shell"
|
||||
command = "bin/app-cpp"
|
||||
debug-adapter = "gdb"
|
||||
env = { OPENIDEA_GDB_FIXTURE = "present" }
|
||||
@@ -0,0 +1,18 @@
|
||||
CFLAGS = -g -O0 -std=c11
|
||||
CXXFLAGS = -g -O0 -std=c++17
|
||||
|
||||
.PHONY: all app-c app-cpp clean
|
||||
|
||||
all: app-c app-cpp
|
||||
|
||||
app-c: bin
|
||||
$(CC) $(CFLAGS) -o bin/app-c src/main.c
|
||||
|
||||
app-cpp: bin
|
||||
$(CXX) $(CXXFLAGS) -o bin/app-cpp src/main.cpp
|
||||
|
||||
bin:
|
||||
mkdir -p bin
|
||||
|
||||
clean:
|
||||
rm -rf bin
|
||||
@@ -0,0 +1,33 @@
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* FNV-1a 64-bit, one byte at a time: enough of a call stack to inspect. */
|
||||
static uint64_t mix(uint64_t h, unsigned char byte) {
|
||||
h ^= byte;
|
||||
h *= 1099511628211ULL;
|
||||
return h;
|
||||
}
|
||||
|
||||
static uint64_t hash(uint64_t h, const char *s) {
|
||||
for (; *s; s++) {
|
||||
h = mix(h, (unsigned char)*s);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
const char *fixture = getenv("OPENIDEA_GDB_FIXTURE");
|
||||
int environment_ok = getenv("PATH") != NULL && fixture != NULL && strcmp(fixture, "present") == 0;
|
||||
const char *words[] = {"openidea", "gdb", "dap"};
|
||||
uint64_t hashes[3];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
hashes[i] = hash(1469598103934665603ULL, words[i]);
|
||||
}
|
||||
uint64_t value = hashes[0];
|
||||
// Set a breakpoint here (QA: debugger) and inspect `value`.
|
||||
value = hash(1469598103934665603ULL, words[2]);
|
||||
printf("%s %016llx\n", words[2], (unsigned long long)value);
|
||||
return environment_ok ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#include <iostream>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
static std::string banner(const std::string &project, int n) {
|
||||
return project + " #" + std::to_string(n);
|
||||
}
|
||||
|
||||
int main() {
|
||||
const char *fixture = std::getenv("OPENIDEA_GDB_FIXTURE");
|
||||
int environment_ok = std::getenv("PATH") != nullptr && fixture != nullptr && std::strcmp(fixture, "present") == 0;
|
||||
std::vector<std::string> log;
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
log.push_back(banner("openidea", i));
|
||||
}
|
||||
std::string line;
|
||||
// Set a breakpoint here (QA: debugger) and inspect `line`.
|
||||
line = banner("openidea", 3);
|
||||
std::cout << line << "\n";
|
||||
return environment_ok ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/vendor/
|
||||
/composer.lock
|
||||
/.phpunit.cache/
|
||||
/.phpunit.result.cache
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Shipping\Dimensions;
|
||||
use App\Shipping\Parcel;
|
||||
use App\Shipping\Route;
|
||||
use App\Shipping\Shipment;
|
||||
use App\Shipping\Tariff;
|
||||
use App\Shipping\ThresholdCustoms;
|
||||
use Uri\Rfc3986\Uri;
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
function money(int $cents): string
|
||||
{
|
||||
return $cents / 100
|
||||
|> (fn (float $dollars): string => number_format($dollars, 2))
|
||||
|> (fn (string $amount): string => '$'.$amount);
|
||||
}
|
||||
|
||||
$tariff = new Tariff(['air' => 1000, 'ground' => 500]);
|
||||
$customs = new ThresholdCustoms;
|
||||
$tracker = new Uri('https://track.example.com');
|
||||
$shipment = new Shipment('FL-0412', Route::Air)
|
||||
->add(new Parcel('PCL-0001', 'Northwind Books', 1200, new Dimensions(30, 20, 10), 4500))
|
||||
->add(new Parcel('PCL-0002', 'Bluebird Electronics', 2400, new Dimensions(40, 30, 20), 6000))
|
||||
->add(new Parcel('PCL-0003', 'Bluebird Electronics', 800, new Dimensions(20, 15, 10), 5500));
|
||||
|
||||
foreach ($shipment->byStore() as $store => $parcels) {
|
||||
$freightCents = 0;
|
||||
foreach ($parcels as $parcel) {
|
||||
$cents = $tariff->freightCents($parcel, $shipment->route);
|
||||
$freightCents += $cents;
|
||||
printf("%s %.2f kg billable freight %s\n", $parcel->code, $parcel->billableGrams($shipment->route) / 1000, money($cents));
|
||||
}
|
||||
|
||||
$charges = $customs->assess($parcels, $freightCents);
|
||||
$summary = $charges->isDutyFree() ? 'duty-free' : sprintf('VAT %s, clearance fee %s', money($charges->vatCents), money($charges->feeCents));
|
||||
echo "$store: $summary\n";
|
||||
}
|
||||
|
||||
$heaviest = $shipment->heaviest(); // Set a breakpoint here
|
||||
if ($heaviest !== null) {
|
||||
echo "Heaviest: $heaviest->code, tracking {$heaviest->trackingUrl($tracker)->toString()}\n";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "openidea/php-app",
|
||||
"description": "OpenIDEA QA fixture: shipping quotes and customs charges for forwarded parcels.",
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.29",
|
||||
"pestphp/pest": "^5.0",
|
||||
"phpstan/phpstan": "^2.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"quote": "php bin/quote.php",
|
||||
"lint": "pint --test",
|
||||
"analyse": "phpstan analyse"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
- bin
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="default">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"preset": "laravel",
|
||||
"rules": {
|
||||
"cast_spaces": {
|
||||
"space": "single"
|
||||
},
|
||||
"phpdoc_line_span": {
|
||||
"case": "single",
|
||||
"class": "single",
|
||||
"const": "single",
|
||||
"function": "single",
|
||||
"method": "single",
|
||||
"other": "single",
|
||||
"property": "single",
|
||||
"trait_import": "single"
|
||||
},
|
||||
"not_operator_with_successor_space": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shipping;
|
||||
|
||||
final readonly class Charges
|
||||
{
|
||||
public function __construct(
|
||||
public int $vatCents,
|
||||
public int $feeCents,
|
||||
) {}
|
||||
|
||||
public static function dutyFree(): self
|
||||
{
|
||||
return new self(0, 0);
|
||||
}
|
||||
|
||||
public function isDutyFree(): bool
|
||||
{
|
||||
return $this->vatCents === 0 && $this->feeCents === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shipping;
|
||||
|
||||
interface CustomsPolicy
|
||||
{
|
||||
public string $name { get; }
|
||||
|
||||
/** @param list<Parcel> $parcels */
|
||||
public function assess(array $parcels, int $freightCents): Charges;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shipping;
|
||||
|
||||
final class Dimensions
|
||||
{
|
||||
public int $volumetricGrams {
|
||||
get => intdiv($this->lengthCm * $this->widthCm * $this->heightCm, 5);
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
public readonly int $lengthCm,
|
||||
public readonly int $widthCm,
|
||||
public readonly int $heightCm,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shipping;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class InvalidStatusTransition extends DomainException
|
||||
{
|
||||
public static function between(Parcel $parcel, ParcelStatus $next): self
|
||||
{
|
||||
return new self(sprintf("%s can't move from %s to %s", $parcel->code, $parcel->status->value, $next->value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shipping;
|
||||
|
||||
use NoDiscard;
|
||||
use Uri\Rfc3986\Uri;
|
||||
|
||||
final readonly class Parcel
|
||||
{
|
||||
public function __construct(
|
||||
public string $code,
|
||||
public string $store,
|
||||
public int $weightGrams,
|
||||
public Dimensions $dimensions,
|
||||
public int $valueCents,
|
||||
public ParcelStatus $status = ParcelStatus::Expected,
|
||||
) {}
|
||||
|
||||
public function billableGrams(Route $route): int
|
||||
{
|
||||
return max($this->weightGrams, $this->dimensions->volumetricGrams, $route->minimumGrams());
|
||||
}
|
||||
|
||||
#[NoDiscard('a parcel is immutable, use the returned one')]
|
||||
public function moveTo(ParcelStatus $next): self
|
||||
{
|
||||
if (!$this->status->canMoveTo($next)) {
|
||||
throw InvalidStatusTransition::between($this, $next);
|
||||
}
|
||||
|
||||
return clone ($this, ['status' => $next]);
|
||||
}
|
||||
|
||||
public function trackingUrl(Uri $tracker): Uri
|
||||
{
|
||||
return $tracker->withPath("/parcels/$this->code");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shipping;
|
||||
|
||||
enum ParcelStatus: string
|
||||
{
|
||||
case Expected = 'expected';
|
||||
case Received = 'received';
|
||||
case AwaitingContents = 'awaiting_contents';
|
||||
case ContentsConfirmed = 'contents_confirmed';
|
||||
case InTransit = 'in_transit';
|
||||
case Arrived = 'arrived';
|
||||
case Held = 'held';
|
||||
case Ready = 'ready';
|
||||
case Delivered = 'delivered';
|
||||
|
||||
public function canMoveTo(self $next): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Expected => $next === self::Received,
|
||||
self::Received => $next === self::AwaitingContents,
|
||||
self::AwaitingContents => $next === self::ContentsConfirmed,
|
||||
self::ContentsConfirmed => $next === self::InTransit,
|
||||
self::InTransit => $next === self::Arrived,
|
||||
self::Arrived => in_array($next, [self::Ready, self::Held], true),
|
||||
self::Held => $next === self::Ready,
|
||||
self::Ready => $next === self::Delivered,
|
||||
self::Delivered => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shipping;
|
||||
|
||||
use Deprecated;
|
||||
|
||||
enum Route: string
|
||||
{
|
||||
case Air = 'air';
|
||||
case Ground = 'ground';
|
||||
|
||||
#[Deprecated('sea freight is no longer offered', since: '2026-01')]
|
||||
case Sea = 'sea';
|
||||
|
||||
public function minimumGrams(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Air => 100,
|
||||
default => 1000,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shipping;
|
||||
|
||||
use ArrayIterator;
|
||||
use Countable;
|
||||
use IteratorAggregate;
|
||||
|
||||
/** @implements IteratorAggregate<int, Parcel> */
|
||||
final class Shipment implements Countable, IteratorAggregate
|
||||
{
|
||||
/** @var list<Parcel> */
|
||||
public private(set) array $parcels = [];
|
||||
|
||||
public function __construct(
|
||||
public readonly string $flight,
|
||||
public readonly Route $route,
|
||||
) {}
|
||||
|
||||
public function add(Parcel $parcel): self
|
||||
{
|
||||
$this->parcels[] = $parcel;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function heaviest(): ?Parcel
|
||||
{
|
||||
return $this->parcels
|
||||
|> (fn (array $parcels): array => $this->sortedByBillableWeight($parcels))
|
||||
|> array_last(...);
|
||||
}
|
||||
|
||||
public function isReadyForDeparture(): bool
|
||||
{
|
||||
return array_all($this->parcels, fn (Parcel $parcel): bool => $parcel->status === ParcelStatus::ContentsConfirmed);
|
||||
}
|
||||
|
||||
/** @return array<string, list<Parcel>> */
|
||||
public function byStore(): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($this->parcels as $parcel) {
|
||||
$groups[$parcel->store][] = $parcel;
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/** @return ArrayIterator<int, Parcel> */
|
||||
public function getIterator(): ArrayIterator
|
||||
{
|
||||
return new ArrayIterator($this->parcels);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->parcels);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Parcel> $parcels
|
||||
* @return list<Parcel>
|
||||
*/
|
||||
private function sortedByBillableWeight(array $parcels): array
|
||||
{
|
||||
usort($parcels, fn (Parcel $a, Parcel $b): int => $a->billableGrams($this->route) <=> $b->billableGrams($this->route));
|
||||
|
||||
return $parcels;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shipping;
|
||||
|
||||
final readonly class Tariff
|
||||
{
|
||||
/** @param array<string, int> $centsPerKg */
|
||||
public function __construct(private array $centsPerKg) {}
|
||||
|
||||
public function freightCents(Parcel $parcel, Route $route): int
|
||||
{
|
||||
$grams = $parcel->billableGrams($route);
|
||||
|
||||
return intdiv($grams * $this->centsPerKg[$route->value] + 999, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shipping;
|
||||
|
||||
use Closure;
|
||||
use Override;
|
||||
|
||||
final class ThresholdCustoms implements CustomsPolicy
|
||||
{
|
||||
private const int DutyFreeCents = 100_00;
|
||||
|
||||
private const int DutyFreeGrams = 20_000;
|
||||
|
||||
private const int VatPercent = 20;
|
||||
|
||||
private const Closure ValueCents = static function (Parcel $parcel): int {
|
||||
return $parcel->valueCents;
|
||||
};
|
||||
|
||||
private const Closure WeightGrams = static function (Parcel $parcel): int {
|
||||
return $parcel->weightGrams;
|
||||
};
|
||||
|
||||
#[Override]
|
||||
public string $name = 'threshold';
|
||||
|
||||
#[Override]
|
||||
public function assess(array $parcels, int $freightCents): Charges
|
||||
{
|
||||
$valueCents = $parcels |> (fn (array $all): array => array_map(self::ValueCents, $all)) |> array_sum(...);
|
||||
$grams = $parcels |> (fn (array $all): array => array_map(self::WeightGrams, $all)) |> array_sum(...);
|
||||
$parcelCount = count($parcels);
|
||||
|
||||
if (!$this->exceedsDutyFree($valueCents, $grams)) {
|
||||
return Charges::dutyFree();
|
||||
}
|
||||
|
||||
// TODO: add the duty by goods code
|
||||
$vatCents = intdiv(($valueCents + $freightCents) * self::VatPercent, 100);
|
||||
|
||||
return new Charges($vatCents, $this->clearanceFee($valueCents));
|
||||
}
|
||||
|
||||
private function exceedsDutyFree(int $valueCents, int $grams): bool
|
||||
{
|
||||
if ($valueCents > self::DutyFreeCents) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $grams > self::DutyFreeGrams;
|
||||
}
|
||||
|
||||
private function clearanceFee(int $valueCents): int
|
||||
{
|
||||
// FIXME: read the tiers from configuration
|
||||
return match (true) {
|
||||
$valueCents <= 1_000_00 => 5_00,
|
||||
$valueCents <= 5_000_00 => 25_00,
|
||||
default => 75_00,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Shipping\Charges;
|
||||
use App\Shipping\CustomsPolicy;
|
||||
use App\Shipping\Parcel;
|
||||
use App\Shipping\Tariff;
|
||||
use App\Shipping\ThresholdCustoms;
|
||||
|
||||
arch('value objects are final and readonly')
|
||||
->expect([Charges::class, Parcel::class, Tariff::class])
|
||||
->toBeFinal()
|
||||
->toBeReadonly();
|
||||
|
||||
arch('customs policies implement the contract')
|
||||
->expect(ThresholdCustoms::class)
|
||||
->toImplement(CustomsPolicy::class);
|
||||
|
||||
arch('application code does not contain debug helpers')
|
||||
->expect('App')
|
||||
->not->toUse(['dd', 'dump', 'var_dump']);
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Shipping\Dimensions;
|
||||
use App\Shipping\InvalidStatusTransition;
|
||||
use App\Shipping\ParcelStatus;
|
||||
use App\Shipping\Route;
|
||||
use Tests\TestSupport\ParcelFactory;
|
||||
use Uri\Rfc3986\Uri;
|
||||
|
||||
it('bills the volumetric weight of a light bulky parcel', function (): void {
|
||||
$parcel = ParcelFactory::make(weightGrams: 2400, dimensions: new Dimensions(40, 30, 20));
|
||||
|
||||
expect($parcel->billableGrams(Route::Air))->toBe(4800);
|
||||
});
|
||||
|
||||
it('bills the route minimum for a small parcel', function (): void {
|
||||
$parcel = ParcelFactory::make(weightGrams: 300, dimensions: new Dimensions(10, 10, 2));
|
||||
|
||||
expect($parcel->billableGrams(Route::Ground))->toBe(1000);
|
||||
});
|
||||
|
||||
it('moves to the next status in a copy', function (): void {
|
||||
$expected = ParcelFactory::make();
|
||||
|
||||
$received = $expected->moveTo(ParcelStatus::Received);
|
||||
|
||||
expect($received->status)->toBe(ParcelStatus::Received)
|
||||
->and($expected->status)->toBe(ParcelStatus::Expected)
|
||||
->and($received->code)->toBe($expected->code);
|
||||
});
|
||||
|
||||
it('rejects a skipped status', function (): void {
|
||||
(void) ParcelFactory::make()->moveTo(ParcelStatus::Received)->moveTo(ParcelStatus::InTransit);
|
||||
})->throws(InvalidStatusTransition::class);
|
||||
|
||||
it('links to its tracking page', function (): void {
|
||||
$url = ParcelFactory::make()->trackingUrl(new Uri('https://track.example.com'));
|
||||
|
||||
expect($url->toString())->toBe('https://track.example.com/parcels/PCL-0009');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Shipping\Dimensions;
|
||||
use App\Shipping\ParcelStatus;
|
||||
use App\Shipping\Route;
|
||||
use App\Shipping\Shipment;
|
||||
use Tests\TestSupport\ParcelFactory;
|
||||
|
||||
it('finds the heaviest parcel by billable weight', function (): void {
|
||||
$bulky = ParcelFactory::make(weightGrams: 900, dimensions: new Dimensions(40, 30, 20));
|
||||
$dense = ParcelFactory::make(weightGrams: 3000, dimensions: new Dimensions(10, 10, 10));
|
||||
|
||||
$shipment = new Shipment('FL-0412', Route::Air)->add($dense)->add($bulky);
|
||||
|
||||
expect($shipment->heaviest())->toBe($bulky);
|
||||
});
|
||||
|
||||
it('departs once every parcel has confirmed contents', function (): void {
|
||||
$confirmed = ParcelFactory::make()
|
||||
->moveTo(ParcelStatus::Received)
|
||||
->moveTo(ParcelStatus::AwaitingContents)
|
||||
->moveTo(ParcelStatus::ContentsConfirmed);
|
||||
|
||||
$shipment = new Shipment('FL-0412', Route::Air)->add($confirmed);
|
||||
|
||||
expect($shipment->isReadyForDeparture())->toBeTrue()
|
||||
->and($shipment->add(ParcelFactory::make())->isReadyForDeparture())->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps its parcel list read-only from outside', function (): void {
|
||||
$shipment = new Shipment('FL-0412', Route::Air);
|
||||
|
||||
expect(fn () => $shipment->parcels = [])->toThrow(Error::class);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Shipping\ThresholdCustoms;
|
||||
use Tests\TestSupport\ParcelFactory;
|
||||
|
||||
it('clears a small purchase duty-free', function (): void {
|
||||
$charges = new ThresholdCustoms()->assess([ParcelFactory::make(valueCents: 4500)], 1200);
|
||||
|
||||
expect($charges->isDutyFree())->toBeTrue();
|
||||
});
|
||||
|
||||
it('sums the parcels of one store', function (): void {
|
||||
$charges = new ThresholdCustoms()->assess([ParcelFactory::make(valueCents: 6000), ParcelFactory::make(valueCents: 5500)], 2000);
|
||||
|
||||
expect($charges->vatCents)->toBe(2700)
|
||||
->and($charges->feeCents)->toBe(500);
|
||||
});
|
||||
|
||||
it('intentionally fails (set OPENIDEA_FAIL=1)', function (): void {
|
||||
expect(1)->toBe(2);
|
||||
})->skip(getenv('OPENIDEA_FAIL') !== '1', 'intentionally fails (set OPENIDEA_FAIL=1)');
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\TestSupport;
|
||||
|
||||
use App\Shipping\Dimensions;
|
||||
use App\Shipping\Parcel;
|
||||
|
||||
final class ParcelFactory
|
||||
{
|
||||
public static function make(int $weightGrams = 1000, ?Dimensions $dimensions = null, int $valueCents = 5000): Parcel
|
||||
{
|
||||
return new Parcel('PCL-0009', 'Bluebird Electronics', $weightGrams, $dimensions ?? new Dimensions(20, 20, 10), $valueCents);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ const config: KnipConfig = {
|
||||
// Plugins' web pages are projects of their own (own package.json, checked by plugins/build.sh and CI).
|
||||
ignore: ["plugins/**/web-src/**"],
|
||||
ignoreExportsUsedInFile: true,
|
||||
// lint-staged formats Rust files with the toolchain's rustfmt.
|
||||
ignoreBinaries: ["rustfmt"],
|
||||
// System tools used by formatting and language smoke fixtures.
|
||||
ignoreBinaries: ["rustfmt", "composer", "make"],
|
||||
compilers: {
|
||||
// Fonts come in through `@import` in global.css.
|
||||
css: (text: string) => [...text.matchAll(/(?<=@)import[^;]+/g)].join("\n"),
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"e2e": "playwright test",
|
||||
"perf": "node perf/run.ts",
|
||||
"perf:rust": "node perf/rust.ts",
|
||||
"release:prepare": "node scripts/release-prepare.ts"
|
||||
"release:prepare": "node scripts/release-prepare.ts",
|
||||
"nightly:prepare": "node scripts/nightly-prepare.ts"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "pnpm lint-staged"
|
||||
|
||||
@@ -306,6 +306,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
|
||||
| `events.input` | medium | `action-performed` and `input-mode-changed` events |
|
||||
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
|
||||
| `net:<host>` | medium | `host::fetch` over HTTPS (also loopback HTTP); `*.example.com` matches subdomains |
|
||||
| `net:ask` | medium | Ask the user at the first request to an HTTPS (or loopback) host that is not declared; one answer per host, revocable; API 1.5 |
|
||||
| `net.http:<host>` | medium | Unencrypted HTTP to that host; API 1.4, same wildcard rules as `net:` |
|
||||
| `ipc:<path>` | high | Local Unix sockets or Windows named pipes on the project machine; API 1.4 |
|
||||
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set`, `secret_set`, `secret_delete` |
|
||||
@@ -373,6 +374,9 @@ working. New in 1.5:
|
||||
- [Notifications for a project](#notifications-with-commands):
|
||||
`Project::notify` shows a notification in a window of that project only,
|
||||
also from scheduled or background work.
|
||||
- [Permissions](#capabilities): `net:ask` lets a plugin connect to HTTPS hosts
|
||||
one at a time, asking at first use instead of listing every host
|
||||
(see _[Network and secrets](#network-and-secrets)_).
|
||||
|
||||
### API 1.4
|
||||
|
||||
@@ -1211,6 +1215,29 @@ connecting to the destination; HTTPS → HTTP also requires `net.http:<host>`,
|
||||
even on loopback. Responses are limited to 8 MiB. Requests run on the project
|
||||
machine. A manifest using `net.http:` or `ipc:` declares `engines.openidea = "^1.4"`.
|
||||
|
||||
A plugin with `net:ask` (API 1.5) may reach an HTTPS (or loopback) host that is
|
||||
not in the manifest: the first request asks the user, naming the plugin and the
|
||||
full host, with **Allow once**, **Always allow** and **Deny**. "Always" is
|
||||
remembered per exact, normalized host in the grant ledger and never asks again; the allowed
|
||||
hosts are listed in the plugin's permissions (Settings › Plugins), where each
|
||||
can be removed to ask again. Remote sessions offer **Allow for this session**
|
||||
and **Deny** instead: approval is kept only in memory until the remote connection ends.
|
||||
Unattended calls to a host that is not yet allowed return `denied` without a dialog,
|
||||
so the plugin should tell the user to run the action once interactively. Plain HTTP
|
||||
to other hosts never goes through this flow: declare `net.http:<host>`. A
|
||||
manifest using `net:ask` declares `engines.openidea = "^1.5"`.
|
||||
|
||||
A host answer covers all ports of that host, like `net:<host>`, and never
|
||||
covers subdomains or wildcard patterns. IDNs are shown in ASCII (punycode);
|
||||
IP literals use their canonical form. Redirects are checked against the
|
||||
request's grants and cannot open another permission dialog. Deny is the
|
||||
default button; dismissal refuses the request. Only one network question per
|
||||
plugin may be pending; a refusal or dismissal suppresses further questions
|
||||
for 30 seconds, even for other hosts. Hidden windows refuse new questions and
|
||||
close pending ones. Automatic event handlers and providers do not ask.
|
||||
Removing `net:ask` during an update or revoking it clears remembered hosts;
|
||||
uninstalling removes the plugin's ledger entry.
|
||||
|
||||
`ipc:<path>` grants a local byte stream: an absolute Unix socket path (the host
|
||||
expands `~/`, `$TMPDIR/` and `$XDG_RUNTIME_DIR/`) or `\\.\pipe\<name>` on Windows.
|
||||
Only a grant's last path segment may contain `*`, for example
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NIGHTLY_ENDPOINT, nightlyVersion, setNightlyIdentity } from "./nightly-prepare.ts";
|
||||
|
||||
describe("nightlyVersion", () => {
|
||||
it("is one patch above the current version, numbered by its stamp", () => {
|
||||
expect(nightlyVersion("0.1.39", "202611081417")).toBe("0.1.40-nightly.202611081417");
|
||||
});
|
||||
|
||||
it.each([undefined, "", "2026", "012611081417", "20261108141a"])("rejects the stamp %s", (stamp) => {
|
||||
expect(() => nightlyVersion("0.1.39", stamp)).toThrow("stamp");
|
||||
});
|
||||
|
||||
it("rejects a current version that is not a release version", () => {
|
||||
expect(() => nightlyVersion("0.1.40-nightly.202611081417", "202611081417")).toThrow("expected a version");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setNightlyIdentity", () => {
|
||||
const conf = JSON.parse(setNightlyIdentity(readFileSync("src-tauri/tauri.conf.json", "utf8")));
|
||||
|
||||
it("is an app of its own that updates from the nightly manifest", () => {
|
||||
expect(conf.productName).toBe("OpenIDEA Nightly");
|
||||
expect(conf.identifier).toBe("ai.l0ng.openidea.nightly");
|
||||
expect(conf.plugins.updater.endpoints).toEqual([NIGHTLY_ENDPOINT]);
|
||||
});
|
||||
|
||||
it("leaves out the bundles that reject prerelease versions", () => {
|
||||
expect(conf.bundle.targets).not.toContain("msi");
|
||||
expect(conf.bundle.targets).not.toContain("rpm");
|
||||
expect(conf.bundle.createUpdaterArtifacts).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Prepares a nightly build's commit: `node scripts/nightly-prepare.ts 202611081417` sets the
|
||||
* version one patch above the current one, with that stamp as its prerelease number, in the
|
||||
* files `release:prepare` covers, and gives tauri.conf.json the nightly app's identity, so it
|
||||
* installs next to the stable app and updates from its own manifest. Prints the version.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { cargoVersion, edit, parseVersion, setCargoVersion, setJsonVersion } from "./release-prepare.ts";
|
||||
|
||||
export const NIGHTLY_ENDPOINT = "https://github.com/l0ng-ai/openidea/releases/download/nightly/latest.json";
|
||||
|
||||
/** `0.1.40-nightly.202611081417` from `0.1.39`: always above the last stable version and the last nightly. */
|
||||
export function nightlyVersion(current: string, stamp: string | undefined): string {
|
||||
if (!stamp || !/^[1-9]\d{11}$/.test(stamp)) throw new Error(`expected a YYYYMMDDHHMM stamp, got ${stamp}`);
|
||||
const [major, minor, patch] = parseVersion(current).split(".").map(Number);
|
||||
return `${major}.${minor}.${patch! + 1}-nightly.${stamp}`;
|
||||
}
|
||||
|
||||
/** MSI and RPM reject prerelease versions, so a nightly build leaves them out of its bundles. */
|
||||
export function setNightlyIdentity(json: string): string {
|
||||
const conf = JSON.parse(json);
|
||||
conf.productName = "OpenIDEA Nightly";
|
||||
conf.identifier = "ai.l0ng.openidea.nightly";
|
||||
conf.bundle.targets = ["app", "dmg", "appimage", "deb", "nsis"];
|
||||
conf.plugins.updater.endpoints = [NIGHTLY_ENDPOINT];
|
||||
return `${JSON.stringify(conf, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const version = nightlyVersion(cargoVersion(readFileSync(join(root, "Cargo.toml"), "utf8")), process.argv[2]);
|
||||
|
||||
edit(join(root, "Cargo.toml"), (text) => setCargoVersion(text, version));
|
||||
edit(join(root, "package.json"), (text) => setJsonVersion(text, version));
|
||||
edit(join(root, "src-tauri/tauri.conf.json"), (text) => setNightlyIdentity(setJsonVersion(text, version)));
|
||||
const cargo = spawnSync("cargo", ["update", "--workspace"], {
|
||||
cwd: root,
|
||||
stdio: ["ignore", 2, 2],
|
||||
});
|
||||
if (cargo.status !== 0) throw new Error("cargo update --workspace failed");
|
||||
console.log(version);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`nightly-prepare: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function syncApiSnapshot(root: string): { from: string; to: string; copie
|
||||
return { from, to, copied: true };
|
||||
}
|
||||
|
||||
function edit(path: string, change: (text: string) => string) {
|
||||
export function edit(path: string, change: (text: string) => string) {
|
||||
const before = readFileSync(path, "utf8");
|
||||
const after = change(before);
|
||||
if (after === before) throw new Error(`${path}: nothing changed`);
|
||||
|
||||
+58
-4
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Debugging each language's `fixtures/` project the way the editor does: a breakpoint on the
|
||||
* entry point's "Set a breakpoint here" line, Debug from the file's context, then the paused
|
||||
* frame, its variables, evaluation, Step Over and Resume, with the debugger the IDE installs.
|
||||
* entry point's "Set a breakpoint here" line, Debug from the file's context (or a saved
|
||||
* configuration, when the debugger is an explicit choice), then the paused frame, its
|
||||
* variables, evaluation, Step Over and Resume, with the debugger the IDE installs (GDB:
|
||||
* the toolchain's own, 14+ for its DAP interpreter).
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
||||
@@ -33,6 +35,10 @@ interface Case {
|
||||
setup?: (root: string) => void;
|
||||
/** The IDE's tool that debugs it. */
|
||||
debugger: string;
|
||||
/** A system tool that ships with the toolchain (the IDE does not install it). */
|
||||
system?: boolean;
|
||||
/** A saved configuration to debug (the debugger is its explicit choice), instead of the file's context. */
|
||||
configId?: string;
|
||||
/** The entry point, debugged from its context. */
|
||||
file: string;
|
||||
/** A variable of the paused frame. */
|
||||
@@ -89,6 +95,37 @@ const CASES: Record<string, Case> = {
|
||||
variable: "library",
|
||||
evaluate: ["library", /Library/],
|
||||
},
|
||||
// GDB's own DAP interpreter (GDB 14+), the configuration's explicit debugger choice:
|
||||
// the C/C++ default stays lldb-dap. GDB 15.0.50.20240403-0ubuntu1 says `15`.
|
||||
c: {
|
||||
fixture: "c-app",
|
||||
setup: (root) => execFileSync("make", ["app-c"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "gdb",
|
||||
system: true,
|
||||
configId: "user:app-c",
|
||||
file: "src/main.c",
|
||||
variable: "value",
|
||||
evaluate: ["print value", / = [0-9]/],
|
||||
},
|
||||
cpp: {
|
||||
fixture: "c-app",
|
||||
setup: (root) => execFileSync("make", ["app-cpp"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "gdb",
|
||||
system: true,
|
||||
configId: "user:app-cpp",
|
||||
file: "src/main.cpp",
|
||||
variable: "line",
|
||||
evaluate: ["print line", / = /],
|
||||
},
|
||||
php: {
|
||||
fixture: "php-app",
|
||||
setup: (root) =>
|
||||
execFileSync("composer", ["install", "--no-interaction", "--no-progress"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "php-debug",
|
||||
file: "bin/quote.php",
|
||||
variable: "$shipment",
|
||||
evaluate: ["count($shipment)", /^3$/],
|
||||
},
|
||||
};
|
||||
|
||||
let deactivate: (() => void) | null = null;
|
||||
@@ -205,7 +242,12 @@ const pausedOrEnded = () => {
|
||||
async function smoke(language: string) {
|
||||
const c = CASES[language]!;
|
||||
const root = await open(c);
|
||||
await install(c.debugger);
|
||||
if (c.system) {
|
||||
// GDB ships with the toolchain (14+ for its DAP interpreter); the IDE does not install it.
|
||||
execFileSync(c.debugger, ["--version"], { stdio: "pipe" });
|
||||
} else {
|
||||
await install(c.debugger);
|
||||
}
|
||||
|
||||
const path = join(root, c.file);
|
||||
const content = readFileSync(path, "utf8");
|
||||
@@ -213,7 +255,13 @@ async function smoke(language: string) {
|
||||
const line = marker + 1;
|
||||
addBreakpoint(path, line);
|
||||
|
||||
await executeAction("run.debugContext", { path, content });
|
||||
if (c.configId) {
|
||||
const config = (await call(commands.runConfigs())).find((cfg) => cfg.id === c.configId);
|
||||
expect(config, c.configId).toBeTruthy();
|
||||
await executeAction("debug.startConfig", config);
|
||||
} else {
|
||||
await executeAction("run.debugContext", { path, content });
|
||||
}
|
||||
if (c.request) void requestWhenListening(c.request);
|
||||
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
|
||||
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
|
||||
@@ -227,6 +275,12 @@ async function smoke(language: string) {
|
||||
|
||||
const value = await evaluate(c.evaluate[0], "repl", s);
|
||||
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
|
||||
if (c.debugger === "gdb") {
|
||||
const expression = await evaluate(c.variable, "watch", s);
|
||||
expect(expression.value).toBeTruthy();
|
||||
const environment = await evaluate("environment_ok", "watch", s);
|
||||
expect(environment.value).toBe("1");
|
||||
}
|
||||
|
||||
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
|
||||
// (and its breakpoint) again.
|
||||
|
||||
@@ -122,6 +122,19 @@ const CASES: Record<string, Case> = {
|
||||
file: "LibraryTest.kt",
|
||||
},
|
||||
},
|
||||
php: {
|
||||
fixture: "php-app",
|
||||
setup: (root) =>
|
||||
execFileSync("composer", ["install", "--no-interaction", "--no-progress"], { cwd: root, stdio: "ignore" }),
|
||||
run: { config: "composer:quote", prints: "Bluebird Electronics: VAT $34.20, clearance fee $5.00" },
|
||||
tests: {
|
||||
config: "phpunit",
|
||||
failing: (c) => env(c, { OPENIDEA_FAIL: "1" }),
|
||||
passed: 13,
|
||||
failed: "it intentionally fails (set OPENIDEA_FAIL=1)",
|
||||
file: "ThresholdCustomsTest.php",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
+5
-3
@@ -1,6 +1,8 @@
|
||||
fn main() {
|
||||
let root =
|
||||
dunce::canonicalize(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap()).expect("worktree root");
|
||||
// Read when the script runs: a path compiled into the script would outlive a move of the checkout.
|
||||
let manifest_dir =
|
||||
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"));
|
||||
let root = dunce::canonicalize(manifest_dir.parent().unwrap()).expect("worktree root");
|
||||
println!("cargo:rustc-env=OPENIDEA_WORKTREE_ROOT={}", root.display());
|
||||
// Windows (MSVC): link the Common-Controls v6 manifest into every binary of
|
||||
// this package, not only the app's resources, so test executables such as
|
||||
@@ -9,7 +11,7 @@ fn main() {
|
||||
let mut windows = tauri_build::WindowsAttributes::new();
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
windows = tauri_build::WindowsAttributes::new_without_app_manifest();
|
||||
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("windows-app-manifest.xml");
|
||||
let manifest = manifest_dir.join("windows-app-manifest.xml");
|
||||
println!("cargo:rerun-if-changed={}", manifest.display());
|
||||
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
|
||||
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display());
|
||||
|
||||
@@ -501,6 +501,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
plugin::plugin_install_dir,
|
||||
plugin::plugin_grant,
|
||||
plugin::plugin_set_capability,
|
||||
plugin::plugin_revoke_net_host,
|
||||
plugin::plugin_link,
|
||||
plugin::plugin_unlink,
|
||||
plugin::plugin_assets,
|
||||
|
||||
@@ -235,6 +235,14 @@ pub async fn plugin_set_capability(
|
||||
blocking(move || host.set_capability(&id, &capability, granted)).await
|
||||
}
|
||||
|
||||
/// Revokes one host remembered through net:ask.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn plugin_revoke_net_host(state: State<'_, AppState>, id: String, host: String) -> IdeResult<PluginInfo> {
|
||||
let plugins = state.plugins.clone();
|
||||
blocking(move || plugins.revoke_net_host(&id, &host)).await
|
||||
}
|
||||
|
||||
/// Links a development plugin folder (`openidea-plugin link` does the same from a terminal).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -413,9 +413,10 @@ fn config_endpoints<R: tauri::Runtime>(app: &AppHandle<R>) -> Vec<String> {
|
||||
|
||||
/// The endpoints tried in order: `source` first, the other source when it fails or has
|
||||
/// no manifest. Bundles are signed, so either source's manifest and downloads verify.
|
||||
/// No official endpoint (a build without updates) means no mirror either.
|
||||
fn source_endpoints(official: Vec<String>, source: UpdateSource) -> Vec<String> {
|
||||
if official.is_empty() {
|
||||
/// No official endpoint (a build without updates) means no mirror either. Nightly builds
|
||||
/// aren't mirrored: the mirror's manifest would move them to the stable version.
|
||||
fn source_endpoints(official: Vec<String>, source: UpdateSource, nightly: bool) -> Vec<String> {
|
||||
if official.is_empty() || nightly {
|
||||
return official;
|
||||
}
|
||||
let mirror = vec![CNB_ENDPOINT.to_string()];
|
||||
@@ -425,6 +426,10 @@ fn source_endpoints(official: Vec<String>, source: UpdateSource) -> Vec<String>
|
||||
}
|
||||
}
|
||||
|
||||
fn is_nightly(version: &str) -> bool {
|
||||
version.contains("-nightly.")
|
||||
}
|
||||
|
||||
fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
if !updates_configured(app) {
|
||||
@@ -432,7 +437,7 @@ fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plug
|
||||
}
|
||||
let endpoints = match update_endpoint_env() {
|
||||
Some(url) => vec![url],
|
||||
None => source_endpoints(config_endpoints(app), source),
|
||||
None => source_endpoints(config_endpoints(app), source, is_nightly(&app.package_info().version.to_string())),
|
||||
};
|
||||
let endpoints = endpoints
|
||||
.iter()
|
||||
@@ -646,10 +651,22 @@ mod tests {
|
||||
#[test]
|
||||
fn chosen_source_is_tried_first_and_the_other_after_it() {
|
||||
let github = "https://github.com/l0ng-ai/openidea/releases/latest/download/latest.json".to_string();
|
||||
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Github), [github.clone(), CNB_ENDPOINT.into()]);
|
||||
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb), [CNB_ENDPOINT.into(), github]);
|
||||
assert_eq!(
|
||||
source_endpoints(vec![github.clone()], UpdateSource::Github, false),
|
||||
[github.clone(), CNB_ENDPOINT.into()]
|
||||
);
|
||||
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb, false), [CNB_ENDPOINT.into(), github]);
|
||||
// A build without an update endpoint stays without one.
|
||||
assert!(source_endpoints(Vec::new(), UpdateSource::Cnb).is_empty());
|
||||
assert!(source_endpoints(Vec::new(), UpdateSource::Cnb, false).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nightly_builds_update_from_their_own_manifest_whatever_the_source() {
|
||||
let nightly = "https://github.com/l0ng-ai/openidea/releases/download/nightly/latest.json".to_string();
|
||||
assert_eq!(source_endpoints(vec![nightly.clone()], UpdateSource::Cnb, true), vec![nightly.clone()]);
|
||||
assert_eq!(source_endpoints(vec![nightly.clone()], UpdateSource::Github, true), [nightly]);
|
||||
assert!(is_nightly("0.1.40-nightly.202611081417"));
|
||||
assert!(!is_nightly("0.1.40"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+13
-1
@@ -1108,6 +1108,8 @@ export const commands = {
|
||||
pluginGrant: (id: string, consent: Consent) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_grant", { id, consent })),
|
||||
/** Grants or revokes one declared capability. */
|
||||
pluginSetCapability: (id: string, capability: string, granted: boolean) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_set_capability", { id, capability, granted })),
|
||||
/** Revokes one host remembered through net:ask. */
|
||||
pluginRevokeNetHost: (id: string, host: string) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_revoke_net_host", { id, host })),
|
||||
/** Links a development plugin folder (`openidea-plugin link` does the same from a terminal). */
|
||||
pluginLink: (path: string) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_link", { path })),
|
||||
pluginUnlink: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_unlink", { id })),
|
||||
@@ -6693,6 +6695,11 @@ export type PluginInfo = {
|
||||
hasCode: boolean,
|
||||
/** Declared capabilities with risk and grant state. */
|
||||
capabilities: CapabilityInfo[],
|
||||
/**
|
||||
* HTTPS hosts the user allowed through `net:ask` ("always allow"),
|
||||
* none of them declared by the manifest; each can be revoked.
|
||||
*/
|
||||
netHosts: string[],
|
||||
/** What it contributes while live (point + label). */
|
||||
contributions: ContributionInfo[],
|
||||
/** `activation` events of the manifest. */
|
||||
@@ -7112,7 +7119,12 @@ export type ProjectStructureSettings = {
|
||||
* What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`,
|
||||
* `ui.input-multiline`, `ui.confirm`).
|
||||
*/
|
||||
export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string } | { kind: "pickDirectory"; title: string; initial: string | null };
|
||||
export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string } | { kind: "pickDirectory"; title: string; initial: string | null } |
|
||||
/**
|
||||
* The host's own question under `net:ask` (never plugin-initiated): the
|
||||
* plugin reached an HTTPS host its manifest does not list.
|
||||
*/
|
||||
{ kind: "netPermission"; host: string; sessionOnly: boolean };
|
||||
|
||||
export type ProxyKind = "http" | "socks";
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { pasteEditorClipboard } from "./cm/clipboard";
|
||||
import type { ReactNode } from "react";
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import { create } from "zustand";
|
||||
import { setContext } from "@/platform/context";
|
||||
@@ -211,6 +212,16 @@ export function tabIcon(input: EditorInput, kinds: EditorKinds): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** A kind's trailing marker (status dot …); `null` when it has none. */
|
||||
export function tabTrailing(input: EditorInput, kinds: EditorKinds): ReactNode {
|
||||
const kind = kinds.get(input.kind);
|
||||
try {
|
||||
return kind?.trailing?.(input) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorController implements SlotHost {
|
||||
manager: DocumentManager;
|
||||
readonly slots = new Map<string, EditorSlot>();
|
||||
|
||||
@@ -46,7 +46,15 @@ import {
|
||||
type TabItem,
|
||||
type TabsDnd,
|
||||
} from "@/ui";
|
||||
import { editorController, isTextInput, tabIcon, tabTitle, useTabs, type VirtualInputData } from "../controller";
|
||||
import {
|
||||
editorController,
|
||||
isTextInput,
|
||||
tabIcon,
|
||||
tabTitle,
|
||||
tabTrailing,
|
||||
useTabs,
|
||||
type VirtualInputData,
|
||||
} from "../controller";
|
||||
import { useDocMeta } from "../model/document";
|
||||
import { inputDirty } from "../model/customDocuments";
|
||||
import type { LayoutNode, SplitEdge, Tab } from "../model/tabs";
|
||||
@@ -193,6 +201,8 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
: names.filter((n) => n === name).length > 1 && t.input.path
|
||||
? basename(dirname(t.input.path))
|
||||
: null;
|
||||
const kindMark = tabTrailing(t.input, kinds);
|
||||
const pin = t.pinned ? <Icon name="pin" size={11} className={styles.pin} label="Pinned" /> : null;
|
||||
return {
|
||||
id: t.id,
|
||||
label: (
|
||||
@@ -213,7 +223,13 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
color: colors[i] || undefined,
|
||||
tint: tints[i] || undefined,
|
||||
tooltip: virtualTooltip(t.input) ?? (showFullPathOnHover ? (t.input.path ?? name) : undefined),
|
||||
trailing: t.pinned ? <Icon name="pin" size={11} className={styles.pin} label="Pinned" /> : undefined,
|
||||
trailing:
|
||||
kindMark || pin ? (
|
||||
<>
|
||||
{kindMark}
|
||||
{pin}
|
||||
</>
|
||||
) : undefined,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** The tab strip's agent status indicator: spinner while the task is busy, dot once done (#2233). */
|
||||
import { Badge, Spinner, tooltipAttrs } from "@/ui";
|
||||
import { isBusy, statusLabel } from "./model";
|
||||
import { useAgents } from "./store";
|
||||
|
||||
export function TaskStatusMark({ task }: { task: string }) {
|
||||
const status = useAgents((s) => s.tasks.get(task)?.status);
|
||||
if (!status) return null;
|
||||
const busy = isBusy(status);
|
||||
const label = statusLabel(status);
|
||||
return (
|
||||
<span {...tooltipAttrs(label, undefined, true)} style={{ display: "inline-flex", alignItems: "center" }}>
|
||||
{busy ? (
|
||||
<Spinner size={10} variant="spokes" label={label} />
|
||||
) : (
|
||||
<Badge
|
||||
dot
|
||||
tone={status === "ready" ? "success" : status === "failed" || status === "limited" ? "danger" : "neutral"}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* Agents tool window lists tasks by what they need from the user, a task opens as an
|
||||
* editor tab (conversation + changed files), and New Task is an editor page.
|
||||
*/
|
||||
import { createElement } from "react";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
@@ -22,6 +23,7 @@ import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW } from "
|
||||
import { sendPaths, sendSelection } from "./references";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import { connect, disconnect, openHome, useAgents } from "./store";
|
||||
import { TaskStatusMark } from "./TaskStatusMark";
|
||||
import { taskActions } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
@@ -88,6 +90,8 @@ export function activate(): () => void {
|
||||
return useAgents.getState().tasks.get(data.task)?.title ?? data.title ?? "Agent Task";
|
||||
},
|
||||
icon: () => "toolwindow-agents",
|
||||
// Busy / done marker in the tab's trailing slot (#2233).
|
||||
trailing: (input) => createElement(TaskStatusMark, { task: (input.data as { task: string }).task }),
|
||||
// Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`).
|
||||
restorable: true,
|
||||
// Rebuilding a long conversation on every switch back is slow.
|
||||
|
||||
@@ -297,7 +297,7 @@ function onEvent(e: AgentEvent) {
|
||||
case "task": {
|
||||
const prev = s.tasks.get(e.task.id);
|
||||
useAgents.setState({ tasks: new Map(s.tasks).set(e.task.id, e.task) });
|
||||
if (prev && prev.title !== e.task.title) notifyTabTitlesChanged();
|
||||
if (prev && (prev.title !== e.task.title || prev.status !== e.task.status)) notifyTabTitlesChanged();
|
||||
if (e.task.status === "archived") setUnread(e.task.id, false);
|
||||
else if (attentionChange(prev?.status, e.task.status)) markUnread(e.task.id);
|
||||
notifyChange(prev, e.task);
|
||||
|
||||
@@ -403,6 +403,68 @@ describe("Check for Updates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("automatic nightly update prompts", () => {
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const update = (currentVersion: string): UpdateInfo => ({
|
||||
version: "0.1.41",
|
||||
currentVersion,
|
||||
notes: null,
|
||||
date: null,
|
||||
});
|
||||
const startupCheck = async (currentVersion: string) => {
|
||||
notes = [];
|
||||
await notifyUpdate(update(currentVersion));
|
||||
return notes.length;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: 10 * DAY });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("leaves stable prompts to the backend announcement policy", async () => {
|
||||
expect(await startupCheck("0.1.40")).toBe(1);
|
||||
expect(await startupCheck("0.1.40")).toBe(1);
|
||||
});
|
||||
|
||||
it("asks a nightly build at most once a day", async () => {
|
||||
const nightly = "0.1.41-nightly.202611081417";
|
||||
expect(await startupCheck(nightly)).toBe(1);
|
||||
vi.setSystemTime(11 * DAY - 1);
|
||||
expect(await startupCheck(nightly)).toBe(0);
|
||||
vi.setSystemTime(11 * DAY);
|
||||
expect(await startupCheck(nightly)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not prompt twice a day for periodic nightly checks, but always answers a manual check", async () => {
|
||||
const nightly = "0.1.41-nightly.202611081417";
|
||||
const dispose = installUpdateChecks();
|
||||
await tick();
|
||||
try {
|
||||
const available = updateListeners.get("updateAvailable")!;
|
||||
available({ update: update(nightly) });
|
||||
await tick();
|
||||
expect(notes).toHaveLength(1);
|
||||
vi.setSystemTime(10 * DAY + 4 * 60 * 60 * 1000);
|
||||
available({ update: { ...update(nightly), version: "0.1.42-nightly.202611090200" } });
|
||||
await tick();
|
||||
expect(notes).toHaveLength(1);
|
||||
const done = checkForUpdates();
|
||||
check.resolve(ok({ enabled: true, update: update(nightly) }));
|
||||
await done;
|
||||
expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.41 is available", userInitiated: true });
|
||||
notes = [];
|
||||
vi.setSystemTime(11 * DAY);
|
||||
available({ update: { ...update(nightly), version: "0.1.42-nightly.202611091400" } });
|
||||
await tick();
|
||||
expect(notes).toHaveLength(1);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSource", () => {
|
||||
const withSettings = (updateSource: string | undefined, locale: string | undefined) =>
|
||||
useProject.setState({ settings: { general: { updateSource }, appearance: { locale } } as unknown as Settings });
|
||||
|
||||
@@ -127,6 +127,17 @@ async function runCheck() {
|
||||
}
|
||||
}
|
||||
|
||||
const NIGHTLY_PROMPT_KEY = "openidea.update.nightlyPromptedAt";
|
||||
const NIGHTLY_PROMPT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Nightly builds come out more often than anyone wants to be asked: at most one prompt a day. */
|
||||
function nightlyPromptDue(now = Date.now()): boolean {
|
||||
const last = Number(localStorage.getItem(NIGHTLY_PROMPT_KEY));
|
||||
if (now - last < NIGHTLY_PROMPT_INTERVAL_MS) return false;
|
||||
localStorage.setItem(NIGHTLY_PROMPT_KEY, String(now));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Start one backend schedule after this window can receive its notifications. */
|
||||
export function installUpdateChecks(): () => void {
|
||||
let disposed = false;
|
||||
@@ -154,6 +165,7 @@ export async function notifyUpdate(update: UpdateInfo, userInitiated = false): P
|
||||
);
|
||||
if (!claimed) return;
|
||||
}
|
||||
if (!userInitiated && update.currentVersion.includes("-nightly.") && !nightlyPromptDue()) return;
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: t("app.update.available", { version: update.version }),
|
||||
|
||||
@@ -253,7 +253,13 @@ describe("run config → launch config", () => {
|
||||
it("delve: debug mode for run, test mode for test", () => {
|
||||
const run = launchConfigFor(rc({ kind: "go", command: "go", args: ["run", "."], debugAdapter: "delve" }), "/p")!;
|
||||
expect(run.program).toBe("/p");
|
||||
expect(extra(run)).toEqual({ mode: "debug", __goToolArgs: [], __goUseTags: true });
|
||||
expect(extra(run)).toMatchObject({
|
||||
mode: "debug",
|
||||
__goTargets: ["."],
|
||||
__goExecutable: "go",
|
||||
__goToolArgs: [],
|
||||
__goUseTags: true,
|
||||
});
|
||||
const sub = launchConfigFor(
|
||||
rc({ kind: "go", command: "go", args: ["run", "./cmd/api"], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
@@ -263,8 +269,8 @@ describe("run config → launch config", () => {
|
||||
rc({ kind: "go", command: "go", args: ["test", "./..."], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(extra(test)).toEqual({ mode: "test", __goToolArgs: [], __goUseTags: true });
|
||||
expect(test.program).toBe("/p");
|
||||
expect(extra(test)).toMatchObject({ mode: "test", __goTargets: ["./..."], __goToolArgs: [], __goUseTags: true });
|
||||
expect(test.program).toBe("/p/...");
|
||||
// A gutter test / subtest: the package, and `-run` for the test binary.
|
||||
const one = launchConfigFor(
|
||||
rc({
|
||||
@@ -299,7 +305,7 @@ describe("run config → launch config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("native programs under CodeLLDB / lldb-dap: program, args, cwd, env", () => {
|
||||
it("native programs under CodeLLDB / lldb-dap / GDB: program, args, cwd, env", () => {
|
||||
const run = rc({ command: "./build/app", args: ["-v"], cwd: "c", env: { K: "v" } });
|
||||
const lldb = launchConfigFor({ ...run, debugAdapter: "codelldb" }, "/p")!;
|
||||
expect(lldb).toMatchObject({
|
||||
@@ -314,6 +320,16 @@ describe("run config → launch config", () => {
|
||||
expect(dap).toMatchObject({ adapter: "lldb-dap", program: "/p/c/build/app" });
|
||||
// lldb-dap takes `KEY=VALUE` strings.
|
||||
expect(extra(dap)).toEqual({ env: ["K=v"] });
|
||||
// GDB's DAP launch takes the environment as an object, like the default flow.
|
||||
const gdb = launchConfigFor({ ...run, debugAdapter: "gdb" }, "/p")!;
|
||||
expect(gdb).toMatchObject({
|
||||
adapter: "gdb",
|
||||
program: "/p/c/build/app",
|
||||
args: ["-v"],
|
||||
cwd: "/p/c",
|
||||
env: { K: "v" },
|
||||
});
|
||||
expect(gdb.extraJson).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["native", "cmake-application"])(
|
||||
@@ -419,6 +435,50 @@ it("netcoredbg launches the evaluated DLL with resolved profile arguments, env a
|
||||
expect(launchConfigFor({ ...c, params: {} }, "/repo")).toBeNull();
|
||||
});
|
||||
|
||||
describe("Go debug target resolution inputs", () => {
|
||||
it("preserves import paths and multiple source files for Go to resolve", () => {
|
||||
const imported = launchConfigFor(
|
||||
rc({ kind: "go", command: "/sdk/bin/go", args: ["test", "example.com/app/calc"], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(imported.program).toBe("example.com/app/calc");
|
||||
expect(extra(imported)).toMatchObject({
|
||||
__goTargets: ["example.com/app/calc"],
|
||||
__goExecutable: "/sdk/bin/go",
|
||||
__goBuildDir: "/p",
|
||||
});
|
||||
const files = launchConfigFor(
|
||||
rc({ kind: "go", command: "go", args: ["run", "main.go", "helper.go", "hello"], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(files.args).toEqual(["hello"]);
|
||||
expect(extra(files).__goTargets).toEqual(["main.go", "helper.go"]);
|
||||
const packages = launchConfigFor(
|
||||
rc({ kind: "go", command: "go", args: ["build", "./a", "./b"], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(extra(packages).__goTargets).toEqual(["./a", "./b"]);
|
||||
});
|
||||
|
||||
it("keeps a typed recursive package target and the module's selected compiler", () => {
|
||||
const launch = launchConfigFor(
|
||||
rc({
|
||||
kind: "go",
|
||||
command: "./sdk/bin/go",
|
||||
args: ["test", "./..."],
|
||||
debugAdapter: "delve",
|
||||
params: { goKind: "test", scope: "package", target: "./...", module: "backend" },
|
||||
}),
|
||||
"/p",
|
||||
)!;
|
||||
expect(extra(launch)).toMatchObject({
|
||||
__goTargets: ["./..."],
|
||||
__goBuildDir: "/p/backend",
|
||||
__goExecutable: "/p/backend/sdk/bin/go",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("raw and typed Go debug configurations inherit custom tags unless explicitly disabled", () => {
|
||||
for (const typed of [false, true]) {
|
||||
for (const useTags of [undefined, "true", "false"]) {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
const debugEvaluateIn = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
status: "ok" as const,
|
||||
data: { name: "result", value: "4", typeName: "int", variablesReference: 0, evaluateName: null },
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
const ok = <T,>(data: T) => Promise.resolve({ status: "ok" as const, data });
|
||||
@@ -8,6 +15,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
debugEvaluateIn,
|
||||
debugScopes: () => ok([{ name: "Locals", variablesReference: 1, expensive: false }]),
|
||||
debugVariables: () =>
|
||||
ok([
|
||||
@@ -31,6 +39,8 @@ vi.mock("@/workbench/confirmTerminate", () => ({ confirmTerminate }));
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { useDebug, type DebugSession } from "../state";
|
||||
import { DebugTabs, DebugToolWindow } from "../ui/DebugToolWindow";
|
||||
import { DebuggerOverlays } from "../ui/Overlays";
|
||||
import { useOverlays } from "../ui/popoverStore";
|
||||
|
||||
const session: DebugSession = {
|
||||
id: 1,
|
||||
@@ -53,6 +63,26 @@ const session: DebugSession = {
|
||||
};
|
||||
|
||||
describe("debug tool window", () => {
|
||||
it.each(["gdb", "debugpy"])("evaluates expressions in the variable field and dialog under %s", async (adapter) => {
|
||||
debugEvaluateIn.mockClear();
|
||||
useDebug.setState({ sessions: { 1: { ...session, adapter } }, active: 1 });
|
||||
const { unmount } = render(<DebugToolWindow />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Threads & Variables" }));
|
||||
const field = screen.getByPlaceholderText(/Evaluate expression/);
|
||||
fireEvent.change(field, { target: { value: "count + 1" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
const context = adapter === "gdb" ? "watch" : "repl";
|
||||
await vi.waitFor(() => expect(debugEvaluateIn).toHaveBeenCalledWith(1, 7, "count + 1", context));
|
||||
unmount();
|
||||
debugEvaluateIn.mockClear();
|
||||
useOverlays.setState({ evaluate: { expression: "count + 1" } });
|
||||
const dialog = render(<DebuggerOverlays />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Evaluate" }));
|
||||
await vi.waitFor(() => expect(debugEvaluateIn).toHaveBeenCalledWith(1, 7, "count + 1", context));
|
||||
dialog.unmount();
|
||||
useOverlays.setState({ evaluate: null });
|
||||
});
|
||||
|
||||
it("offers Hot Reload and Hot Restart for Dart and Flutter sessions only", () => {
|
||||
const hot = () => ["debug.hotReload", "debug.hotRestart"].map((name) => !!screen.queryByRole("button", { name }));
|
||||
useDebug.setState({ sessions: { 1: { ...session, status: "running" } }, active: 1 });
|
||||
|
||||
@@ -16,7 +16,7 @@ function join(base: string, p: string) {
|
||||
|
||||
/** A command path with a directory part resolved against `cwd`; bare names stay for PATH lookup. */
|
||||
function resolveCommand(cmd: string, cwd: string) {
|
||||
return cmd.includes("/") ? join(cwd, cmd) : cmd;
|
||||
return /[\\/]/.test(cmd) ? join(cwd, cmd) : cmd;
|
||||
}
|
||||
|
||||
function splitAtDashDash(args: readonly string[]): [string[], string[]] {
|
||||
@@ -225,6 +225,9 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
|
||||
dlvCwd: module,
|
||||
...(p.scope === "file" ? { __goFiles: target.split("|").map((f) => join(module, f.trim())) } : {}),
|
||||
__goToolArgs: toolArgs,
|
||||
...(p.scope === "file" ? {} : { __goTargets: [target] }),
|
||||
__goBuildDir: module,
|
||||
__goExecutable: resolveCommand(rc.command, module),
|
||||
__goUseTags: p.useTags !== "false",
|
||||
...(p.outputDir ? { output: join(root, `${p.outputDir}/go-debug`) } : {}),
|
||||
});
|
||||
@@ -261,9 +264,11 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
|
||||
}
|
||||
const selection =
|
||||
test &&
|
||||
/^-(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(a);
|
||||
if (test && /^-(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) {
|
||||
testFlags.push(a.replace(/^-/, "-test."));
|
||||
/^-(?:test\.)?(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(
|
||||
a,
|
||||
);
|
||||
if (test && /^-(?:test\.)?(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) {
|
||||
testFlags.push(a.startsWith("-test.") ? a : a.replace(/^-/, "-test."));
|
||||
} else if (selection) {
|
||||
const value = selection[2] ?? rest[++i];
|
||||
if (value !== undefined) testFlags.push(`-test.${selection[1]}`, value);
|
||||
@@ -271,18 +276,25 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
|
||||
if (test && (a === "-json" || a === "-c")) continue;
|
||||
flags.push(a);
|
||||
if (!a.includes("=") && !boolFlags.has(a) && rest[i + 1] !== undefined) flags.push(rest[++i]!);
|
||||
} else if (!test && targets.length) {
|
||||
} else if (
|
||||
sub === "run" &&
|
||||
targets.length &&
|
||||
!(a.endsWith(".go") && targets.every((target) => target.endsWith(".go")))
|
||||
) {
|
||||
programArgs = rest.slice(i);
|
||||
break;
|
||||
} else targets.push(a);
|
||||
}
|
||||
const target = targets[0] ?? ".";
|
||||
out.program = target.endsWith("/...") ? cwd : join(cwd, target);
|
||||
out.program = !target.startsWith(".") && !isAbsolute(target) && !target.endsWith(".go") ? target : join(cwd, target);
|
||||
out.args = [...testFlags, ...programArgs];
|
||||
out.extraJson = JSON.stringify({
|
||||
mode: test ? "test" : "debug",
|
||||
...(flags.length ? { buildFlags: formatArgs(flags) } : {}),
|
||||
__goToolArgs: flags,
|
||||
__goTargets: targets.length ? targets : ["."],
|
||||
__goBuildDir: cwd,
|
||||
__goExecutable: resolveCommand(rc.command, cwd),
|
||||
__goUseTags: rc.params?.useTags !== "false",
|
||||
});
|
||||
return out;
|
||||
@@ -306,7 +318,7 @@ function godotLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
return { ...base(rc, "godot", project), extraJson: JSON.stringify({ project, scene: godotScene(rc.args) }) };
|
||||
}
|
||||
|
||||
/** A native program under CodeLLDB or lldb-dap: program, args, cwd, env. */
|
||||
/** A native program under CodeLLDB, lldb-dap or GDB: program, args, cwd, env. */
|
||||
function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
|
||||
const out: LaunchConfig = { ...base(rc, adapter, cwd), program: resolveCommand(rc.command, cwd), args: [...rc.args] };
|
||||
// lldb-dap takes the environment as `KEY=VALUE` strings.
|
||||
@@ -408,7 +420,7 @@ export function launchConfigFor(rc: RunConfig, root: string, php: string | null
|
||||
if (adapter === "dart") {
|
||||
return rc.params?.request === "attach" ? flutterAttach(rc, cwd, adapter) : flutterLaunch(rc, cwd, adapter);
|
||||
}
|
||||
if (adapter === "codelldb" || adapter === "lldb-dap") {
|
||||
if (adapter === "codelldb" || adapter === "lldb-dap" || adapter === "gdb") {
|
||||
return rc.kind === "cargo" ? cargoLaunch(rc, cwd, adapter) : nativeLaunch(rc, cwd, adapter);
|
||||
}
|
||||
if (adapter === "debugpy") return debugpyLaunch(rc, cwd);
|
||||
|
||||
@@ -512,7 +512,11 @@ function VariablesPane({ session }: { session: DebugSession }) {
|
||||
const e = expression.trim();
|
||||
if (!e) return;
|
||||
try {
|
||||
setLastEval({ expression: e, result: await evaluate(e, "repl", session), error: null });
|
||||
setLastEval({
|
||||
expression: e,
|
||||
result: await evaluate(e, session.adapter === "gdb" ? "watch" : "repl", session),
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setLastEval({ expression: e, result: null, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
@@ -706,7 +706,7 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
|
||||
setHistory(readHistory("evaluate"));
|
||||
historyPos.current = -1;
|
||||
try {
|
||||
const v: Variable = await evaluate(e, "repl", session ?? null);
|
||||
const v: Variable = await evaluate(e, session?.adapter === "gdb" ? "watch" : "repl", session ?? null);
|
||||
setResult({ key: `eval:${e}:${Date.now()}`, kind: "eval", name: e, variable: v, error: null });
|
||||
} catch (err) {
|
||||
setResult({ key: `eval:${e}:${Date.now()}`, kind: "eval", name: e, variable: null, error: errorText(err) });
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
reloadPlugin,
|
||||
setAllInstalledEnabled,
|
||||
setPluginCapability,
|
||||
revokePluginNetHost,
|
||||
setPluginEnabled,
|
||||
uninstallPlugin,
|
||||
updateAllPlugins,
|
||||
@@ -376,7 +377,7 @@ function ProgramPermission({ plugin, cap, disabled }: { plugin: PluginInfo; cap:
|
||||
}
|
||||
|
||||
function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) {
|
||||
if (plugin.capabilities.length === 0) {
|
||||
if (plugin.capabilities.length === 0 && plugin.netHosts.length === 0) {
|
||||
return (
|
||||
<div className={styles.muted}>
|
||||
<T>No special permissions.</T>
|
||||
@@ -402,10 +403,33 @@ function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean })
|
||||
/>
|
||||
</CapabilityRow>
|
||||
))}
|
||||
{plugin.netHosts.map((host) => (
|
||||
<NetHostRow key={host} plugin={plugin} host={host} disabled={busy || !confirmed} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** A host the user allowed through `net:ask` ("always"); removing it asks again next time. */
|
||||
function NetHostRow({ plugin, host, disabled }: { plugin: PluginInfo; host: string; disabled: boolean }) {
|
||||
return (
|
||||
<li className={styles.capabilityItem}>
|
||||
<div className={styles.capabilityHeader}>
|
||||
<span className={styles.mono}>{`net:${host}`}</span>
|
||||
<Badge tone={RISK_LABEL.medium.tone}>{RISK_LABEL.medium.label}</Badge>
|
||||
</div>
|
||||
<p className={styles.capabilityDescription}>
|
||||
{tx("Allowed when the plugin asked at first use. Remove it to be asked again.")}
|
||||
</p>
|
||||
<div className={styles.programPermission}>
|
||||
<Button disabled={disabled} onClick={() => void revokePluginNetHost(plugin.id, host)}>
|
||||
{tx("Remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Homepage({ value }: { value: string }) {
|
||||
let isWebUrl = false;
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useProject } from "@/platform/project";
|
||||
import { pluginThemeRef, useContributedIconThemes } from "@/platform/themes";
|
||||
import { ContextMenuHost, hideContextMenu } from "@/ui";
|
||||
import { InputDialogsHost } from "@/workbench/inputDialogs";
|
||||
import { MessagesHost } from "@/workbench/messages";
|
||||
import {
|
||||
onEndpointsChanged,
|
||||
provideNotificationService,
|
||||
@@ -74,6 +75,7 @@ function plugin(partial: Partial<PluginInfo> & Pick<PluginInfo, "id">): PluginIn
|
||||
error: null,
|
||||
hasCode: true,
|
||||
capabilities: [],
|
||||
netHosts: [],
|
||||
contributions: [],
|
||||
activation: [],
|
||||
warnings: [],
|
||||
@@ -640,6 +642,115 @@ describe("plugin prompts", () => {
|
||||
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("2", null));
|
||||
});
|
||||
|
||||
it("asks about a net:ask host with allow once, always allow and deny", async () => {
|
||||
const user = userEvent.setup();
|
||||
const answerPrompt = vi.fn().mockResolvedValue(undefined);
|
||||
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
|
||||
usePlugins.setState({ plugins: [plugin({ id: "p", name: "Relay" })] });
|
||||
render(<MessagesHost />);
|
||||
|
||||
const ask = (promptId: string) =>
|
||||
act(() => {
|
||||
handlePluginEvent({
|
||||
type: "promptRequested",
|
||||
pluginId: "p",
|
||||
promptId,
|
||||
prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: false },
|
||||
});
|
||||
});
|
||||
ask("9");
|
||||
expect(screen.getByText("Allow Relay to connect to api.example.com?")).toBeDefined();
|
||||
expect(screen.getByText(/The host is not in the plugin's declared permissions\./)).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus();
|
||||
await user.click(screen.getByRole("button", { name: "Always allow" }));
|
||||
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("9", ["always"]));
|
||||
|
||||
ask("10");
|
||||
await user.click(await screen.findByRole("button", { name: "Allow once" }));
|
||||
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("10", ["once"]));
|
||||
|
||||
ask("11");
|
||||
await user.click(await screen.findByRole("button", { name: "Deny" }));
|
||||
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("11", null));
|
||||
});
|
||||
|
||||
it("offers session permission and deny for a remote net:ask prompt", async () => {
|
||||
const user = userEvent.setup();
|
||||
const answerPrompt = vi.fn().mockResolvedValue(undefined);
|
||||
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
|
||||
render(<MessagesHost />);
|
||||
const ask = (promptId: string) =>
|
||||
act(() => {
|
||||
handlePluginEvent({
|
||||
type: "promptRequested",
|
||||
pluginId: "p",
|
||||
promptId,
|
||||
prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: true },
|
||||
});
|
||||
});
|
||||
ask("session-net");
|
||||
expect(screen.queryByRole("button", { name: "Always allow" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Allow once" })).toBeNull();
|
||||
expect(screen.getAllByRole("button")).toHaveLength(2);
|
||||
expect(screen.getByText(/This allows api.example.com until the remote session ends\./)).toBeDefined();
|
||||
expect(screen.queryByText(/revoke it any time/)).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus();
|
||||
await user.click(screen.getByRole("button", { name: "Allow for this session" }));
|
||||
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("session-net", ["session"]));
|
||||
|
||||
ask("denied-session-net");
|
||||
await user.click(await screen.findByRole("button", { name: "Deny" }));
|
||||
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("denied-session-net", null));
|
||||
});
|
||||
|
||||
it("refuses network prompts in a hidden window and closes them when hidden", async () => {
|
||||
const answerPrompt = vi.fn().mockResolvedValue(undefined);
|
||||
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
|
||||
render(<MessagesHost />);
|
||||
const visibility = vi.spyOn(document, "visibilityState", "get");
|
||||
const ask = (promptId: string) =>
|
||||
act(() => {
|
||||
handlePluginEvent({
|
||||
type: "promptRequested",
|
||||
pluginId: "p",
|
||||
promptId,
|
||||
prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: false },
|
||||
});
|
||||
});
|
||||
try {
|
||||
visibility.mockReturnValue("hidden");
|
||||
ask("hidden");
|
||||
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("hidden", null));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
visibility.mockReturnValue("visible");
|
||||
ask("visible");
|
||||
expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus();
|
||||
visibility.mockReturnValue("hidden");
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("visible", null));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
} finally {
|
||||
visibility.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("dismisses a network prompt without allowing the request", async () => {
|
||||
const user = userEvent.setup();
|
||||
const answerPrompt = vi.fn().mockResolvedValue(undefined);
|
||||
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
|
||||
render(<MessagesHost />);
|
||||
act(() => {
|
||||
handlePluginEvent({
|
||||
type: "promptRequested",
|
||||
pluginId: "p",
|
||||
promptId: "dismissed-net",
|
||||
prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: false },
|
||||
});
|
||||
});
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("dismissed-net", null));
|
||||
});
|
||||
|
||||
it("asks multi-line text and confirms on Ctrl+Enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const answerPrompt = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -1232,6 +1343,34 @@ describe("marketplace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["api.example.com", "::1", "ask"])(
|
||||
"revokes remembered host %s separately from capabilities",
|
||||
async (host) => {
|
||||
const user = userEvent.setup();
|
||||
const base = createMemoryPluginsBackend([
|
||||
plugin({
|
||||
id: "p",
|
||||
name: "P",
|
||||
netHosts: [host, "relay.example.org"],
|
||||
capabilities: [
|
||||
{ id: "net:ask", risk: "medium", description: "Ask to connect", optional: true, granted: true },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const revokeNetHost = vi.fn((id: string, host: string) => base.revokeNetHost(id, host));
|
||||
setPluginsBackend({ ...base, revokeNetHost });
|
||||
render(<PluginsPage />);
|
||||
await screen.findByText("net:relay.example.org");
|
||||
expect(screen.getAllByText(`net:${host}`)).toHaveLength(host === "ask" ? 2 : 1);
|
||||
expect(screen.getByText("net:relay.example.org")).toBeInTheDocument();
|
||||
const rows = screen.getAllByRole("button", { name: "Remove" });
|
||||
await user.click(rows[0]!);
|
||||
await waitFor(() => expect(revokeNetHost).toHaveBeenCalledWith("p", host));
|
||||
await waitFor(() => expect(screen.queryAllByText(`net:${host}`)).toHaveLength(host === "ask" ? 1 : 0));
|
||||
expect(screen.getByText("net:relay.example.org")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("offers to roll back an update", async () => {
|
||||
const base = createMemoryPluginsBackend([
|
||||
plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }),
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface PluginsBackend {
|
||||
program(pluginId: string, program: string, scope: "global" | "project"): Promise<string>;
|
||||
pickProgram(): Promise<string | null>;
|
||||
setCapability(id: string, capability: string, granted: boolean): Promise<PluginInfo>;
|
||||
revokeNetHost(id: string, host: string): Promise<PluginInfo>;
|
||||
/** Validated assets (icon theme mapping and SVGs) whose id starts with `prefix`. */
|
||||
assets(pluginId: string, prefix: string): Promise<[string, string][]>;
|
||||
/** `language` / `file` / `view` activation events. */
|
||||
@@ -216,6 +217,7 @@ export const tauriPluginsBackend: PluginsBackend = {
|
||||
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
|
||||
},
|
||||
setCapability: (id, capability, granted) => call(commands.pluginSetCapability(id, capability, granted)),
|
||||
revokeNetHost: (id, host) => call(commands.pluginRevokeNetHost(id, host)),
|
||||
assets: (pluginId, prefix) => call(commands.pluginAssets(pluginId, prefix)),
|
||||
activationEvent: async (kind, value) => {
|
||||
await call(commands.pluginActivationEvent(kind, value));
|
||||
@@ -420,6 +422,7 @@ function samplePlugins(): PluginInfo[] {
|
||||
enabled: true,
|
||||
error: null,
|
||||
capabilities: [],
|
||||
netHosts: [],
|
||||
contributions: [],
|
||||
activation: [],
|
||||
warnings: [],
|
||||
@@ -588,10 +591,17 @@ export function createMemoryPluginsBackend(
|
||||
setCapability: async (id, capability, granted) => {
|
||||
const p = find(id);
|
||||
p.capabilities = p.capabilities.map((c) => (c.id === capability ? { ...c, granted } : c));
|
||||
if (!granted && capability === "net:ask") p.netHosts = [];
|
||||
if (!granted && p.capabilities.some((c) => c.id === capability && !c.optional)) p.status = "needsConsent";
|
||||
emit({ type: "commandsChanged" });
|
||||
return { ...p };
|
||||
},
|
||||
revokeNetHost: async (id, host) => {
|
||||
const p = find(id);
|
||||
p.netHosts = p.netHosts.filter((h) => h !== host);
|
||||
emit({ type: "commandsChanged" });
|
||||
return { ...p };
|
||||
},
|
||||
assets: async () => [],
|
||||
activationEvent: async () => {},
|
||||
catalog: async () => catalog.map((c) => ({ ...c })),
|
||||
|
||||
@@ -39,6 +39,11 @@ export function capabilityDescription(cap: CapabilityInfo): string {
|
||||
"Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing",
|
||||
);
|
||||
}
|
||||
if (cap.id === "net:ask") {
|
||||
return tx(
|
||||
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones",
|
||||
);
|
||||
}
|
||||
if (cap.id.startsWith("net.http:")) {
|
||||
const host = cap.id.slice(9);
|
||||
return tx`Connect to ${host} over unencrypted HTTP (from the machine the project is on)`;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The host's own permission question (`net:ask`): a plugin with the
|
||||
* capability reached an HTTPS host its manifest does not list. One dialog,
|
||||
* one host: allow for this request, remember it, or refuse. Remembered
|
||||
* local hosts appear in the plugin's permissions, where each can be revoked.
|
||||
*/
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { usePlugins } from "./store";
|
||||
|
||||
/** Resolves with the host's answer key, or null when refused. */
|
||||
export async function askNetPermission(
|
||||
pluginId: string,
|
||||
host: string,
|
||||
sessionOnly: boolean,
|
||||
signal: AbortSignal,
|
||||
): Promise<string[] | null> {
|
||||
if (document.visibilityState === "hidden") return null;
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
const visibilityChanged = () => {
|
||||
if (document.visibilityState === "hidden") controller.abort();
|
||||
};
|
||||
signal.addEventListener("abort", abort);
|
||||
document.addEventListener("visibilitychange", visibilityChanged);
|
||||
if (signal.aborted) controller.abort();
|
||||
const name = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId;
|
||||
try {
|
||||
const r = await ask({
|
||||
title: tx`Allow ${name} to connect to ${host}?`,
|
||||
body: [
|
||||
tx("The host is not in the plugin's declared permissions."),
|
||||
" ",
|
||||
tx("This allows this exact host on any port; subdomains need separate permission."),
|
||||
" ",
|
||||
sessionOnly
|
||||
? tx`This allows ${host} until the remote session ends.`
|
||||
: tx`"Always allow" remembers ${host}; revoke it any time in the plugin's permissions.`,
|
||||
],
|
||||
buttons: sessionOnly
|
||||
? [
|
||||
{ id: "session", label: tx("Allow for this session") },
|
||||
{ id: "deny", label: tx("Deny"), primary: true },
|
||||
]
|
||||
: [
|
||||
{ id: "once", label: tx("Allow once") },
|
||||
{ id: "always", label: tx("Always allow") },
|
||||
{ id: "deny", label: tx("Deny"), primary: true },
|
||||
],
|
||||
cancel: false,
|
||||
icon: "question",
|
||||
signal: controller.signal,
|
||||
});
|
||||
return r.button === "session" || r.button === "once" || r.button === "always" ? [r.button] : null;
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort);
|
||||
document.removeEventListener("visibilitychange", visibilityChanged);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { confirm } from "@/workbench/messages";
|
||||
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { editorContext } from "./editorContext";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { askNetPermission } from "./netAsk";
|
||||
import { usePluginT } from "./pluginText";
|
||||
import { setPluginOutput } from "./views/outputStore";
|
||||
import { PLUGIN_LOG_EDITOR_KIND } from "./views/logEditor";
|
||||
@@ -408,13 +409,19 @@ async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] |
|
||||
return (await confirm({ title: prompt.title, message: prompt.message, ok: tx("OK"), signal, verbatim: true }))
|
||||
? []
|
||||
: null;
|
||||
case "netPermission":
|
||||
// The host's own question: answered before this (`answerPrompt`).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function answerPrompt(promptId: string, prompt: Prompt) {
|
||||
async function answerPrompt(promptId: string, prompt: Prompt, pluginId: string) {
|
||||
const controller = new AbortController();
|
||||
openPrompts.set(promptId, controller);
|
||||
const answer = await askUser(prompt, controller.signal);
|
||||
const answer =
|
||||
prompt.kind === "netPermission"
|
||||
? await askNetPermission(pluginId, prompt.host, prompt.sessionOnly, controller.signal)
|
||||
: await askUser(prompt, controller.signal);
|
||||
openPrompts.delete(promptId);
|
||||
if (controller.signal.aborted) return;
|
||||
await pluginsBackend()
|
||||
@@ -599,7 +606,7 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
|
||||
void openExternalUrl(e.url);
|
||||
return true;
|
||||
case "promptRequested":
|
||||
void answerPrompt(e.promptId, e.prompt);
|
||||
void answerPrompt(e.promptId, e.prompt, e.pluginId);
|
||||
return true;
|
||||
case "promptClosed":
|
||||
openPrompts.get(e.promptId)?.abort();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Installed plugins, their UI contributions, the selected plugin theme, and the operations on them. */
|
||||
import { create } from "zustand";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type {
|
||||
ActiveTheme,
|
||||
CapabilityInfo,
|
||||
@@ -191,6 +192,11 @@ export function setPluginCapability(id: string, capability: string, granted: boo
|
||||
);
|
||||
}
|
||||
|
||||
export function revokePluginNetHost(id: string, host: string): Promise<boolean> {
|
||||
const name = nameOf(id);
|
||||
return withBusy(id, tx`Could not revoke ${host} for ${name}`, () => pluginsBackend().revokeNetHost(id, host));
|
||||
}
|
||||
|
||||
export async function refreshCatalog(): Promise<void> {
|
||||
try {
|
||||
usePlugins.setState({ catalog: await pluginsBackend().catalog(), catalogError: null });
|
||||
|
||||
@@ -66,6 +66,7 @@ const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [
|
||||
{ id: "delve", label: "Delve (Go)" },
|
||||
{ id: "codelldb", label: "CodeLLDB (Rust, C, C++)" },
|
||||
{ id: "lldb-dap", label: "lldb-dap (C, C++, Swift)" },
|
||||
{ id: "gdb", label: "GDB (C, C++)" },
|
||||
{ id: "php-debug", label: "php-debug (PHP)" },
|
||||
{ id: "rdbg", label: "rdbg (Ruby)" },
|
||||
];
|
||||
@@ -375,7 +376,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
|
||||
|
||||
const adapterOptions: SelectOption[] = [
|
||||
{ value: "", label: "None" },
|
||||
...ADAPTERS.map((a) => ({ value: a.id, label: a.label })),
|
||||
...ADAPTERS.map((a) => ({ value: a.id, label: tx(a.label) })),
|
||||
];
|
||||
if (config.debugAdapter && !ADAPTERS.some((a) => a.id === config.debugAdapter))
|
||||
adapterOptions.push({
|
||||
|
||||
@@ -222,6 +222,10 @@
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.testRow .testStatus {
|
||||
width: var(--oi-tree-icon-size, 16px);
|
||||
height: var(--oi-tree-icon-size, 16px);
|
||||
}
|
||||
.testName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -242,9 +246,6 @@
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
|
||||
.passed {
|
||||
color: var(--success);
|
||||
}
|
||||
.failed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -72,15 +72,15 @@ function totalDuration(node: TestNode): number | undefined {
|
||||
export function StatusIcon({ status }: { status: TestStatus | null }) {
|
||||
switch (status) {
|
||||
case "passed":
|
||||
return <Icon name="check" size={13} className={styles.passed} label="Passed" />;
|
||||
return <Icon name="test-passed" className={styles.testStatus} label="Passed" />;
|
||||
case "failed":
|
||||
return <Icon name="close" size={13} className={styles.failed} label="Failed" />;
|
||||
return <Icon name="test-failed" className={styles.testStatus} label="Failed" />;
|
||||
case "skipped":
|
||||
return <Icon name="minus" size={13} className={styles.skipped} label="Ignored" />;
|
||||
return <Icon name="test-ignored" className={styles.testStatus} label="Ignored" />;
|
||||
case "running":
|
||||
return <Spinner size={11} />;
|
||||
return <Spinner size={16} variant="spokes" className={styles.testStatus} />;
|
||||
default:
|
||||
return <Icon name="clock" size={13} className={styles.skipped} label="Pending" />;
|
||||
return <Icon name="test-pending" className={styles.testStatus} label="Pending" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,17 +225,17 @@ export function TestTreeView({
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
<IconButton
|
||||
icon="check"
|
||||
icon="test-show-passed"
|
||||
size="small"
|
||||
iconSize={13}
|
||||
iconSize={16}
|
||||
title="Show Passed"
|
||||
pressed={showPassed}
|
||||
onClick={() => toggle("showPassed")}
|
||||
/>
|
||||
<IconButton
|
||||
icon="minus"
|
||||
icon="test-show-ignored"
|
||||
size="small"
|
||||
iconSize={13}
|
||||
iconSize={16}
|
||||
title="Show Ignored"
|
||||
pressed={showIgnored}
|
||||
onClick={() => toggle("showIgnored")}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ToolWindowHeader, useOwnToolWindowHeader } from "@/workbench/toolwindow
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { commands, isTauri } from "@/platform/ipc";
|
||||
import {
|
||||
Badge,
|
||||
dropZoneAt,
|
||||
IconButton,
|
||||
Input,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
Popup,
|
||||
registerDropTarget,
|
||||
showContextMenu,
|
||||
Spinner,
|
||||
SplitGroup,
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
@@ -356,8 +358,19 @@ function GroupTabs({ group }: { group: string }) {
|
||||
const focusRaf = useRef(0);
|
||||
|
||||
const items: TabItem[] = tabs.map((t) => {
|
||||
const status = sessions[t.session]?.status;
|
||||
const session = sessions[t.session];
|
||||
const status = session?.status;
|
||||
const dead = status === "exited" || status === "error";
|
||||
// Running / done marker (#2233): a command at work spins, a finished one
|
||||
// shows a filled dot, an idle shell at the prompt shows none.
|
||||
const busy = !!session?.commandRunning || status === "starting";
|
||||
const mark = busy ? (
|
||||
<Spinner size={10} variant="spokes" label="Running" />
|
||||
) : dead ? (
|
||||
<Badge dot tone={status === "error" ? "danger" : "neutral"}>
|
||||
{status === "error" ? "Failed" : "Exited"}
|
||||
</Badge>
|
||||
) : null;
|
||||
return {
|
||||
id: t.id,
|
||||
label: tabTitle(t, sessions),
|
||||
@@ -365,6 +378,7 @@ function GroupTabs({ group }: { group: string }) {
|
||||
italic: dead,
|
||||
color: dead ? "var(--fg-subtle)" : undefined,
|
||||
tooltip: tabTooltip(t, sessions),
|
||||
trailing: mark ?? undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"GDB (C, C++)": "GDB (C, C++)",
|
||||
"Unknown error": "Unknown error",
|
||||
"Retry Loading Conversation": "Retry Loading Conversation",
|
||||
"No conversation history": "No conversation history",
|
||||
@@ -814,5 +815,19 @@
|
||||
"Set Docker Compose File Detection": "Set Docker Compose File Detection",
|
||||
"Could not save Docker Compose file detection": "Could not save Docker Compose file detection",
|
||||
"Compose Up": "Compose Up",
|
||||
"Start the service at the caret, or all services in the active Docker Compose file": "Start the service at the caret, or all services in the active Docker Compose file"
|
||||
"Start the service at the caret, or all services in the active Docker Compose file": "Start the service at the caret, or all services in the active Docker Compose file",
|
||||
"Allow {name} to connect to {host}?": "Allow {name} to connect to {host}?",
|
||||
"The host is not in the plugin's declared permissions.": "The host is not in the plugin's declared permissions.",
|
||||
"\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.": "\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.",
|
||||
"Allow once": "Allow once",
|
||||
"Always allow": "Always allow",
|
||||
"Remove": "Remove",
|
||||
"Allowed when the plugin asked at first use. Remove it to be asked again.": "Allowed when the plugin asked at first use. Remove it to be asked again.",
|
||||
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones": "Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones",
|
||||
"This allows this exact host on any port; subdomains need separate permission.": "This allows this exact host on any port; subdomains need separate permission.",
|
||||
"Could not revoke {host} for {name}": "Could not revoke {host} for {name}",
|
||||
"host is not remembered for this plugin": "host is not remembered for this plugin",
|
||||
"URL must name one exact host": "URL must name one exact host",
|
||||
"Allow for this session": "Allow for this session",
|
||||
"This allows {host} until the remote session ends.": "This allows {host} until the remote session ends."
|
||||
}
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user