deploy: split desktop releases and fix downloads navigation
Some checks failed
CI / verify (push) Has been cancelled
CI / print-rust (push) Has been cancelled

This commit is contained in:
Admin 2026-09-23 22:53:10 +00:00
parent 8611415452
commit 927d9b298f
10 changed files with 109 additions and 288 deletions

View File

@ -13,3 +13,6 @@ WEB_ORIGIN=https://fieldlens.example.invalid
FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0 FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0
FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download
FIELDLENS_PRINT_DOWNLOAD_DIR=downloads FIELDLENS_PRINT_DOWNLOAD_DIR=downloads
FIELDLENS_DESKTOP_RELEASE_BASE_URL=https://git.arrowstack.dev/Admin/fieldlens-desktop/releases/download
FIELDLENS_DESKTOP_RELEASE_TAG=v0.1.0
FIELDLENS_DESKTOP_DOWNLOAD_BASE_URL=https://git.arrowstack.dev/Admin/fieldlens-desktop/raw/branch/main/downloads

View File

@ -1,202 +1,10 @@
name: Desktop Release name: Desktop Release (moved)
on: on:
push:
branches: [main]
tags:
- "fieldlens-print-v*"
paths:
- "apps/print/**"
- ".gitea/workflows/desktop-release.yml"
pull_request:
paths:
- "apps/print/**"
- ".gitea/workflows/desktop-release.yml"
workflow_dispatch: workflow_dispatch:
permissions:
code: read
releases: read
jobs: jobs:
windows-nsis: moved:
name: Windows x64 NSIS
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - run: echo "Desktop builds now run from Admin/fieldlens-desktop"
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Install Windows cross-build tools
run: |
sudo apt-get update
sudo apt-get install -y nsis lld llvm clang
cargo install --locked cargo-xwin
- name: Install Node dependencies
run: npm install
- name: Build Windows NSIS installer
run: npm run bundle:windows --workspace @fieldlens/print
env:
XWIN_CACHE_DIR: ${{ runner.temp }}/xwin-cache
- name: Stage Windows installer
shell: bash
run: |
set -euo pipefail
source_file="$(find apps/print/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis -maxdepth 1 -type f -name '*-setup.exe' -print -quit)"
test -n "$source_file"
mkdir -p dist/desktop
cp "$source_file" dist/desktop/fieldlens-print-windows-x64-setup.exe
- name: Upload Windows installer
uses: actions/upload-artifact@v3
with:
name: fieldlens-print-windows-x64
path: dist/desktop/fieldlens-print-windows-x64-setup.exe
if-no-files-found: error
retention-days: 14
linux-deb:
name: Linux x64 DEB
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: dtolnay/rust-toolchain@stable
- name: Install Tauri Linux prerequisites
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
curl \
wget \
file \
libwebkit2gtk-4.1-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
- name: Install Node dependencies
run: npm install
- name: Build Debian package
run: npm run bundle:deb --workspace @fieldlens/print
- name: Stage Debian package
shell: bash
run: |
set -euo pipefail
source_file="$(find apps/print/src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' -print -quit)"
test -n "$source_file"
mkdir -p dist/desktop
cp "$source_file" dist/desktop/fieldlens-print-linux-x64.deb
- name: Upload Debian package
uses: actions/upload-artifact@v3
with:
name: fieldlens-print-linux-x64-deb
path: dist/desktop/fieldlens-print-linux-x64.deb
if-no-files-found: error
retention-days: 14
publish-release:
name: Publish tagged desktop release
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/fieldlens-print-v')
needs: [windows-nsis, linux-deb]
runs-on: ubuntu-latest
permissions:
code: read
releases: write
steps:
- name: Download Windows installer
uses: actions/download-artifact@v3
with:
name: fieldlens-print-windows-x64
path: release-assets
- name: Download Debian package
uses: actions/download-artifact@v3
with:
name: fieldlens-print-linux-x64-deb
path: release-assets
- name: Publish Gitea release assets
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF: ${{ gitea.ref }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}"
auth_header="Authorization: token ${GITEA_TOKEN}"
release_tag="$GITEA_REF_NAME"
if [ "$GITEA_REF" = "refs/heads/main" ]; then
release_tag="fieldlens-print-v0.1.0"
fi
status="$(curl -sS -o /tmp/release.json -w '%{http_code}' \
-H "$auth_header" \
"$api/releases/tags/$release_tag")"
if [ "$status" = "404" ]; then
jq -n \
--arg tag "$release_tag" \
--arg sha "$GITEA_SHA" \
'{
tag_name: $tag,
target_commitish: $sha,
name: ("FieldLens Print " + ($tag | sub("^fieldlens-print-"; ""))),
body: "FieldLens Print desktop installers.",
draft: false,
prerelease: false
}' > /tmp/create-release.json
curl -fsS \
-X POST \
-H "$auth_header" \
-H "Content-Type: application/json" \
--data-binary @/tmp/create-release.json \
"$api/releases" > /tmp/release.json
elif [ "$status" != "200" ]; then
cat /tmp/release.json
exit 1
fi
release_id="$(jq -r '.id' /tmp/release.json)"
test "$release_id" != "null"
curl -fsS -H "$auth_header" "$api/releases/$release_id/assets" > /tmp/assets.json
for file in release-assets/fieldlens-print-windows-x64-setup.exe release-assets/fieldlens-print-linux-x64.deb; do
name="$(basename "$file")"
existing_id="$(jq -r --arg name "$name" '.[] | select(.name == $name) | .id' /tmp/assets.json | head -n 1)"
if [ -n "$existing_id" ] && [ "$existing_id" != "null" ]; then
curl -fsS -X DELETE -H "$auth_header" "$api/releases/$release_id/assets/$existing_id"
fi
curl -fsS \
-X POST \
-H "$auth_header" \
-F "attachment=@$file" \
"$api/releases/$release_id/assets?name=$name"
done

View File

@ -1,53 +1,3 @@
FROM node:24-bookworm AS desktop-build
WORKDIR /workspace
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
clang \
curl \
file \
libayatana-appindicator3-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev \
lld \
llvm \
nsis \
patchelf \
pkg-config \
librsvg2-dev \
wget \
&& rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal
ENV PATH="/root/.cargo/bin:$PATH"
RUN rustup target add x86_64-pc-windows-msvc \
&& cargo install --locked cargo-xwin
COPY package.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY apps/print/package.json apps/print/package.json
COPY packages/contracts/package.json packages/contracts/package.json
COPY packages/fieldlens-core/package.json packages/fieldlens-core/package.json
RUN npm install
COPY apps/print apps/print
RUN npm run bundle:windows --workspace @fieldlens/print \
&& npm run bundle:deb --workspace @fieldlens/print \
&& mkdir -p /desktop-artifacts \
&& windows_file="$(find apps/print/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis -maxdepth 1 -type f -name '*-setup.exe' -print -quit)" \
&& linux_file="$(find apps/print/src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' -print -quit)" \
&& test -n "$windows_file" \
&& test -n "$linux_file" \
&& cp "$windows_file" /desktop-artifacts/fieldlens-print-windows-x64-setup.exe \
&& cp "$linux_file" /desktop-artifacts/fieldlens-print-linux-x64.deb
FROM node:24-alpine AS build FROM node:24-alpine AS build
WORKDIR /workspace WORKDIR /workspace
@ -81,7 +31,6 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV STATIC_DIR=/app/web ENV STATIC_DIR=/app/web
ENV FIELDLENS_PRINT_DOWNLOAD_DIR=/app/downloads
COPY --from=build /workspace/package.json /app/package.json COPY --from=build /workspace/package.json /app/package.json
COPY --from=build /workspace/node_modules /app/node_modules COPY --from=build /workspace/node_modules /app/node_modules
@ -91,7 +40,6 @@ COPY --from=build /workspace/migrations /app/migrations
COPY --from=build /workspace/packages/contracts/package.json /app/packages/contracts/package.json COPY --from=build /workspace/packages/contracts/package.json /app/packages/contracts/package.json
COPY --from=build /workspace/packages/contracts/dist /app/packages/contracts/dist COPY --from=build /workspace/packages/contracts/dist /app/packages/contracts/dist
COPY --from=build /workspace/apps/web/dist /app/web COPY --from=build /workspace/apps/web/dist /app/web
COPY --from=desktop-build /desktop-artifacts /app/downloads
USER node USER node
EXPOSE 3000 EXPOSE 3000

View File

@ -62,20 +62,18 @@ The Windows NSIS bundle is configured for `currentUser` installation, so install
### Desktop release artifacts ### Desktop release artifacts
The `.gitea/workflows/desktop-release.yml` workflow packages FieldLens Print on changes to the desktop app, on `main`, and on `fieldlens-print-v*` tags. Desktop packaging is intentionally separate from the PWA/API deployment.
Artifacts: - Private source of truth: `Admin/fieldlens/apps/print`
- Public desktop build/release mirror: `Admin/fieldlens-desktop`
- PWA/API deploy mirror: `Admin/fieldlens-deploy`
- **Windows x64:** NSIS current-user installer from `apps/print/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe` The desktop mirror builds Windows and Linux installers with Gitea Actions and publishes public release assets. Normal FieldLens web/API deployments do not install Rust, Tauri, cargo-xwin, NSIS, or Linux desktop packaging dependencies.
- **Linux x64:** Debian package from `apps/print/src-tauri/target/release/bundle/deb/*.deb`
After a successful Gitea Actions run, download the corresponding `fieldlens-print-windows-x64` or `fieldlens-print-linux-x64-deb` artifact from the workflow run. Current public release assets:
Local packaging commands: - `fieldlens-print-windows-x64-setup.exe`
- `fieldlens-print-linux-x64.deb`
```bash The FieldLens `/downloads` page keeps stable same-site links; the API redirects those links to the configured desktop release tag.
npm run bundle:windows --workspace @fieldlens/print
npm run bundle:deb --workspace @fieldlens/print
```
The Windows package is cross-compiled with `cargo-xwin` and bundled with NSIS. MSI packaging is intentionally not part of the Linux runner workflow because WiX/MSI bundling requires Windows.

View File

@ -14,3 +14,6 @@ STATIC_DIR=
FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0 FIELDLENS_PRINT_RELEASE_TAG=fieldlens-print-v0.1.0
FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download FIELDLENS_PRINT_RELEASE_BASE_URL=https://git.example.invalid/owner/fieldlens/releases/download
FIELDLENS_PRINT_DOWNLOAD_DIR=downloads FIELDLENS_PRINT_DOWNLOAD_DIR=downloads
FIELDLENS_DESKTOP_RELEASE_BASE_URL=https://git.arrowstack.dev/Admin/fieldlens-desktop/releases/download
FIELDLENS_DESKTOP_RELEASE_TAG=v0.1.0
FIELDLENS_DESKTOP_DOWNLOAD_BASE_URL=https://git.arrowstack.dev/Admin/fieldlens-desktop/raw/branch/main/downloads

View File

@ -18,5 +18,5 @@ export const config = {
logLevel: process.env.LOG_LEVEL?.trim() || 'info', logLevel: process.env.LOG_LEVEL?.trim() || 'info',
webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173', webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173',
staticDir: process.env.STATIC_DIR?.trim() || '', staticDir: process.env.STATIC_DIR?.trim() || '',
printDownloadDir: process.env.FIELDLENS_PRINT_DOWNLOAD_DIR?.trim() || 'downloads', desktopDownloadBaseUrl: process.env.FIELDLENS_DESKTOP_DOWNLOAD_BASE_URL?.trim() || 'https://git.arrowstack.dev/Admin/fieldlens-desktop/raw/branch/main/downloads',
} }

View File

@ -1,6 +1,6 @@
import path from 'node:path' import path from 'node:path'
import cors from 'cors' import cors from 'cors'
import express, { type Response } from 'express' import express from 'express'
import helmet from 'helmet' import helmet from 'helmet'
import { FIELDLENS_SCHEMA_VERSION, isSyncRequest, type DeviceRegistration } from '@fieldlens/contracts' import { FIELDLENS_SCHEMA_VERSION, isSyncRequest, type DeviceRegistration } from '@fieldlens/contracts'
import { getAccessRequest, listAccessRequests, requestAccess, reviewAccessRequest } from './access.js' import { getAccessRequest, listAccessRequests, requestAccess, reviewAccessRequest } from './access.js'
@ -61,33 +61,27 @@ app.get('/api/health/live', (_req, res) => {
res.json({ status: 'live', version: process.env.FIELDLENS_VERSION ?? 'dev' }) res.json({ status: 'live', version: process.env.FIELDLENS_VERSION ?? 'dev' })
}) })
const desktopDownloadDir = path.resolve(config.printDownloadDir) function redirectDesktopDownload(res: express.Response, filename: string) {
const base = config.desktopDownloadBaseUrl.replace(/\/$/, '')
function sendDesktopDownload(res: Response, filename: string) { res.setHeader('Cache-Control', 'no-store')
const file = path.join(desktopDownloadDir, filename) res.redirect(302, `${base}/${filename}`)
res.setHeader('Cache-Control', 'public, max-age=300')
res.download(file, filename, (error) => {
if (!error || res.headersSent) return
log('error', 'desktop_download_missing', { filename, file })
res.status(404).json({ error: 'desktop_download_unavailable' })
})
} }
app.get('/downloads/fieldlens-print-windows-x64-setup.exe', (_req, res) => { app.get('/downloads/fieldlens-print-windows-x64-setup.exe', (_req, res) => {
sendDesktopDownload(res, 'fieldlens-print-windows-x64-setup.exe') redirectDesktopDownload(res, 'fieldlens-print-windows-x64-setup.exe')
}) })
app.get('/downloads/fieldlens-print-linux-x64.deb', (_req, res) => { app.get('/downloads/fieldlens-print-linux-x64.deb', (_req, res) => {
sendDesktopDownload(res, 'fieldlens-print-linux-x64.deb') redirectDesktopDownload(res, 'fieldlens-print-linux-x64.deb')
}) })
// Backward-compatible aliases used by the first downloads page. // Backward-compatible aliases used by the first downloads page.
app.get('/downloads/fieldlens-print/windows-x64', (_req, res) => { app.get('/downloads/fieldlens-print/windows-x64', (_req, res) => {
sendDesktopDownload(res, 'fieldlens-print-windows-x64-setup.exe') redirectDesktopDownload(res, 'fieldlens-print-windows-x64-setup.exe')
}) })
app.get('/downloads/fieldlens-print/linux-x64.deb', (_req, res) => { app.get('/downloads/fieldlens-print/linux-x64.deb', (_req, res) => {
sendDesktopDownload(res, 'fieldlens-print-linux-x64.deb') redirectDesktopDownload(res, 'fieldlens-print-linux-x64.deb')
}) })
app.get('/api/health/ready', async (_req, res) => { app.get('/api/health/ready', async (_req, res) => {

View File

@ -15,7 +15,7 @@ const downloads = [
}, },
] ]
export function DesktopDownloadsLink() { export function DesktopDownloadsLink({ onOpen }: { onOpen: () => void }) {
return ( return (
<section className="settings-section desktop-downloads-link"> <section className="settings-section desktop-downloads-link">
<header> <header>
@ -23,22 +23,22 @@ export function DesktopDownloadsLink() {
<div><h2>Desktop app</h2><p>Capture printed barcodes without a physical scan</p></div> <div><h2>Desktop app</h2><p>Capture printed barcodes without a physical scan</p></div>
</header> </header>
<a className="desktop-download-settings-link" href="/downloads"> <button className="desktop-download-settings-link" type="button" onClick={onOpen}>
<span> <span>
<strong>Get the desktop app</strong> <strong>Get the desktop app</strong>
<small>Windows and Linux installers</small> <small>Windows and Linux installers</small>
</span> </span>
<span className="desktop-download-action"><Download size={13} /> Downloads</span> <span className="desktop-download-action"><Download size={13} /> Downloads</span>
</a> </button>
</section> </section>
) )
} }
export function DesktopDownloadsPage() { export function DesktopDownloadsPage({ onBack }: { onBack: () => void }) {
return ( return (
<main className="downloads-page"> <main className="downloads-page">
<div className="downloads-shell"> <div className="downloads-shell">
<a className="downloads-back" href="/"><ArrowLeft size={16} /> Back to FieldLens</a> <button className="downloads-back" type="button" onClick={onBack}><ArrowLeft size={16} /> Back to FieldLens</button>
<header className="downloads-hero"> <header className="downloads-hero">
<span className="downloads-mark">FL</span> <span className="downloads-mark">FL</span>

View File

@ -235,3 +235,23 @@
.downloads-panel-heading { padding: 18px 17px 15px; } .downloads-panel-heading { padding: 18px 17px 15px; }
.downloads-notes { padding: 13px 16px 16px; } .downloads-notes { padding: 13px 16px 16px; }
} }
.host-app-behind-page {
display: none;
}
.desktop-download-settings-link {
width: calc(100% - 36px);
cursor: pointer;
text-align: left;
font: inherit;
}
.downloads-back {
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
font: inherit;
}

View File

@ -1,4 +1,4 @@
import { StrictMode } from 'react' import { StrictMode, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import '../../../packages/fieldlens-core/src/index.css' import '../../../packages/fieldlens-core/src/index.css'
@ -19,15 +19,62 @@ startDeviceHoldTracking()
registerArrowstackReferenceProvider() registerArrowstackReferenceProvider()
installSyncTriggers() installSyncTriggers()
const isDownloadsPage = window.location.pathname === '/downloads' function HostApp() {
const root = createRoot(document.getElementById('root')!) const initialDownloadsPage = window.location.pathname === '/downloads'
const [downloadsOpen, setDownloadsOpen] = useState(initialDownloadsPage)
const [appMounted, setAppMounted] = useState(!initialDownloadsPage)
root.render( useEffect(() => {
<StrictMode> const onPopState = () => {
{isDownloadsPage ? ( const open = window.location.pathname === '/downloads'
<DesktopDownloadsPage /> setDownloadsOpen(open)
) : ( if (!open) setAppMounted(true)
<CoreApp settingsAddon={<><AccountSettings /><DesktopDownloadsLink /></>} /> }
window.addEventListener('popstate', onPopState)
return () => window.removeEventListener('popstate', onPopState)
}, [])
const openDownloads = () => {
setAppMounted(true)
if (window.location.pathname !== '/downloads') {
window.history.pushState({ fieldlensPage: 'downloads' }, '', '/downloads')
}
setDownloadsOpen(true)
}
const closeDownloads = () => {
if (window.location.pathname === '/downloads' && window.history.length > 1) {
window.history.back()
return
}
window.history.replaceState({}, '', '/')
setDownloadsOpen(false)
setAppMounted(true)
}
return (
<>
{appMounted && (
<div className={downloadsOpen ? 'host-app-behind-page' : undefined} aria-hidden={downloadsOpen || undefined}>
<CoreApp
settingsAddon={(
<>
<AccountSettings />
<DesktopDownloadsLink onOpen={openDownloads} />
</>
)} )}
/>
</div>
)}
{downloadsOpen && <DesktopDownloadsPage onBack={closeDownloads} />}
</>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<HostApp />
</StrictMode>, </StrictMode>,
) )