9 Commits

Author SHA1 Message Date
Ludwig Lehnert
81bd495af9 fix: retry busy queue requests
All checks were successful
Release / build-release (push) Successful in 3s
2026-07-01 15:39:14 +00:00
Ludwig Lehnert
b41bab4285 fix: harden queue and request handling
All checks were successful
Release / build-release (push) Successful in 3s
2026-07-01 15:03:29 +00:00
Ludwig Lehnert
b603f4d39c replace daemon with request drop
All checks were successful
Release / build-release (push) Successful in 3s
2026-07-01 14:19:27 +00:00
Ludwig Lehnert
526c40403b fix gui timeout; ignore dist
All checks were successful
Release / build-release (push) Successful in 4s
2026-07-01 13:20:24 +00:00
Ludwig Lehnert
7fc0602818 fix gui runspace cleanup
All checks were successful
Release / build-release (push) Successful in 3s
2026-07-01 13:02:48 +00:00
Ludwig Lehnert
3039562e91 fix gui install runspace
All checks were successful
Release / build-release (push) Successful in 5s
2026-07-01 12:18:59 +00:00
Ludwig Lehnert
91208323ab interactive loading bars and more; (fix)
All checks were successful
Release / build-release (push) Successful in 4s
2026-06-29 15:30:39 +00:00
Ludwig Lehnert
292bd12a4b interactive loading bars and more
All checks were successful
Release / build-release (push) Successful in 4s
2026-06-29 14:56:22 +00:00
Ludwig Lehnert
55d49e0672 fix workflow (1)
All checks were successful
Release / build-release (push) Successful in 5s
2026-06-29 13:32:18 +00:00
5 changed files with 1791 additions and 591 deletions

View File

@@ -38,53 +38,191 @@ jobs:
(cd package && zip -r "../${archive}" LICENSE setup.ps1 PolicyDefinitions)
- name: Create or update release
uses: actions/github-script@v7
shell: bash
env:
SHORT_SHA: ${{ env.SHORT_SHA }}
ARCHIVE: ${{ env.ARCHIVE }}
with:
script: |
const fs = require('fs');
const path = require('path');
ACTION_TOKEN: ${{ github.token }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
const owner = context.repo.owner;
const repo = context.repo.repo;
const tag = process.env.SHORT_SHA;
const archive = process.env.ARCHIVE;
token="${RELEASE_TOKEN:-${ACTION_TOKEN:-}}"
: "${token:?No release token available}"
: "${GITHUB_API_URL:?GITHUB_API_URL is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
: "${GITHUB_SHA:?GITHUB_SHA is required}"
: "${SHORT_SHA:?SHORT_SHA is required}"
: "${ARCHIVE:?ARCHIVE is required}"
let release;
try {
const existing = await github.rest.repos.getReleaseByTag({ owner, repo, tag });
release = existing.data;
} catch (error) {
if (error.status !== 404) throw error;
const created = await github.rest.repos.createRelease({
owner,
repo,
tag_name: tag,
target_commitish: context.sha,
name: tag,
body: `Automated build for ${context.sha}.`,
});
release = created.data;
api="${GITHUB_API_URL}"
repo="${GITHUB_REPOSITORY}"
tag="${SHORT_SHA}"
archive="${ARCHIVE}"
asset_name="$(basename "${archive}")"
encoded_asset_name="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "${asset_name}")"
if [ "${GITEA_ACTIONS:-}" = "true" ]; then
platform="gitea"
auth_header="Authorization: token ${token}"
else
platform="github"
auth_header="Authorization: Bearer ${token}"
fi
tmpdir="$(mktemp -d)"
trap 'rm -rf "${tmpdir}"' EXIT
curl_json() {
local method="$1"
local url="$2"
local out="$3"
shift 3
curl -sS -L \
-X "${method}" \
-H "Accept: application/json" \
-H "${auth_header}" \
"$@" \
-o "${out}" \
-w "%{http_code}" \
"${url}"
}
const assetName = path.basename(archive);
for (const asset of release.assets || []) {
if (asset.name === assetName) {
await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
}
}
python3 - <<'PY' > "${tmpdir}/create-release.json"
import json
import os
const data = fs.readFileSync(archive);
await github.rest.repos.uploadReleaseAsset({
owner,
repo,
release_id: release.id,
name: assetName,
data,
headers: {
'content-type': 'application/zip',
'content-length': data.length,
},
});
print(json.dumps({
"tag_name": os.environ["SHORT_SHA"],
"target_commitish": os.environ["GITHUB_SHA"],
"name": os.environ["SHORT_SHA"],
"body": f"Automated build for {os.environ['GITHUB_SHA']}.",
"draft": False,
"prerelease": False,
}))
PY
release_json="${tmpdir}/release.json"
status="$(
curl_json GET \
"${api}/repos/${repo}/releases/tags/${tag}" \
"${release_json}"
)"
if [ "${status}" = "404" ]; then
status="$(
curl_json POST \
"${api}/repos/${repo}/releases" \
"${release_json}" \
-H "Content-Type: application/json" \
--data-binary "@${tmpdir}/create-release.json"
)"
if [ "${status}" != "201" ]; then
echo "Failed to create release. HTTP ${status}"
cat "${release_json}"
exit 1
fi
elif [ "${status}" != "200" ]; then
echo "Failed to look up release. HTTP ${status}"
cat "${release_json}"
exit 1
fi
release_id="$(
python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["id"])' \
"${release_json}"
)"
assets_json="${tmpdir}/assets.json"
status="$(
curl_json GET \
"${api}/repos/${repo}/releases/${release_id}/assets" \
"${assets_json}"
)"
if [ "${status}" != "200" ]; then
echo "Failed to list release assets. HTTP ${status}"
cat "${assets_json}"
exit 1
fi
existing_asset_id="$(
python3 - "${assets_json}" "${asset_name}" <<'PY'
import json
import sys
assets = json.load(open(sys.argv[1]))
wanted = sys.argv[2]
for asset in assets:
if asset.get("name") == wanted:
print(asset["id"])
break
PY
)"
if [ -n "${existing_asset_id}" ]; then
delete_json="${tmpdir}/delete-asset.json"
if [ "${platform}" = "gitea" ]; then
delete_url="${api}/repos/${repo}/releases/${release_id}/assets/${existing_asset_id}"
else
delete_url="${api}/repos/${repo}/releases/assets/${existing_asset_id}"
fi
status="$(curl_json DELETE "${delete_url}" "${delete_json}")"
case "${status}" in
200|204) ;;
*)
echo "Failed to delete existing asset. HTTP ${status}"
cat "${delete_json}"
exit 1
;;
esac
fi
upload_json="${tmpdir}/upload-asset.json"
if [ "${platform}" = "gitea" ]; then
status="$(
curl -sS -L \
-X POST \
-H "Accept: application/json" \
-H "${auth_header}" \
-F "attachment=@${archive};type=application/zip" \
-o "${upload_json}" \
-w "%{http_code}" \
"${api}/repos/${repo}/releases/${release_id}/assets?name=${encoded_asset_name}"
)"
else
upload_url="$(
python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["upload_url"].split("{", 1)[0])' \
"${release_json}"
)"
status="$(
curl -sS -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "${auth_header}" \
-H "Content-Type: application/zip" \
--data-binary "@${archive}" \
-o "${upload_json}" \
-w "%{http_code}" \
"${upload_url}?name=${encoded_asset_name}"
)"
fi
case "${status}" in
200|201)
echo "Uploaded ${asset_name} to ${platform} release ${tag}."
;;
*)
echo "Failed to upload release asset. HTTP ${status}"
cat "${upload_json}"
exit 1
;;
esac

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

View File

@@ -2,15 +2,19 @@
Windows self-service software catalog backed by `winget`.
Users install approved software through a small GUI or request helper. A SYSTEM scheduled task runs a named-pipe daemon, authenticates the connecting Windows user, validates requests against Group Policy, then runs `winget install --scope machine`.
Users install approved software through a small GUI or request helper. User tools write request files to a controlled drop directory. A SYSTEM scheduled task validates those requests against Group Policy, queues approved work, then runs a SYSTEM `winget` worker.
## What It Does
- Reads allowed software from GPO registry policy.
- Lets users install/uninstall only catalog entries allowed by policy.
- Installs required software automatically.
- Blocks user removal of required software.
- Queues install, uninstall, and upgrade actions.
- Shows current queue task and progress in the GUI.
- Runs `winget upgrade --all` every 2 hours.
- Tracks installed state from `winget export`, not from request history.
- Polls user request files every minute; no long-lived user-facing daemon is required.
## Policy
@@ -68,8 +72,16 @@ Generated files are written to:
C:\ProgramData\__Softwarekatalog\
```
Runtime request files are written below:
```text
C:\ProgramData\__Softwarekatalog\Requests\
C:\ProgramData\__Softwarekatalog\Processed\
C:\ProgramData\__Softwarekatalog\Failed\
```
## Notes
- Requires Windows, Desktop App Installer, and `winget`.
- The daemon runs as `LocalSystem`; keep catalog policy restricted to trusted admins.
- SYSTEM scheduled tasks validate and execute requests; keep catalog policy restricted to trusted admins.
- Package IDs and silent machine-scope support depend on upstream `winget` packages.

View File

@@ -17,7 +17,7 @@ cat > "$out" <<EOF
# ASCII-only bootstrap generated from setup.ps1. Do not edit.
[CmdletBinding()]
param(
[ValidateSet('Install','Daemon','UpgradeAll','ApplyPolicy','ProcessRequest')]
[ValidateSet('Install','UpgradeAll','ApplyPolicy','ProcessQueue','ProcessRequest','ProcessRequests')]
[string]\$Mode = 'Install',
[ValidateSet('Install','Uninstall')]
[string]\$RequestAction = 'Install',

2131
setup.ps1

File diff suppressed because it is too large Load Diff