CodeNomad/scripts/smoke-packaged-resources.cjs
Pascal André c82dd8fbf6
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
fix(notifications): show Windows system notifications and keep their permission (#1109)
## Summary

System notifications on Windows: everything about them in one PR.

1. **Windows data profiles never showed system notifications**
(`fix(windows): show system notifications from every data profile`).
Tauri runs each data profile, such as **dev**, under its own
AppUserModelID (`…client.scope.s<hash>`), and its notifications carry
that ID; Windows shows a desktop app's notifications only under an ID it
knows, and the installer's Start menu shortcut carries the stable ID
alone. Windows recorded every notification (100 on the affected machine)
and displayed none. Every launch now registers the running ID under
`HKCU\Software\Classes\AppUserModelId\<identifier>` (`DisplayName`
CodeNomad, `IconUri` the bundled web app icon
`resources\server\public\pwa-192x192.png`, which the packaged-resource
smoke check requires; `windows_notification_identity.rs`). Electron
always uses the stable ID.
2. **The permission was lost at every restart on Windows.** The
notification plugin's page script reports *denied* there until the page
requests permission, though Windows never asks, so Settings asked again
after each restart. The check now asks the native plugin command, which
answers granted on desktop: granted once, kept.
3. **Notifications were held back whenever the window was visible**
(`fix(notifications): hold system notifications back only while
focused`). With **Notify when the app is focused** off (the default), an
open window stays visible behind other applications or on another
screen, so nothing was sent unless CodeNomad was minimized. Delivery is
now skipped only while the window is visible and focused
(`document.hasFocus()`). The setting's subtitle and the Settings help
say *focused* in all ten languages; `dev-docs/SETTINGS.md` records the
rule.

Out of scope by decision: several CodeNomad windows open on the same
project may each send the event's notification (as they already do when
the setting is on), and focus inside a browser preview counts as leaving
the page.

## Validation

- Affected machine: no profile notification showed before; after
registering its ID as above, a notification sent under it showed in the
notification centre under CodeNomad.
- `cargo test --bins -- windows_notification_identity` (writes the key,
reads back both values).
- `tests/browser/system-notification-focus.test.ts`: real routed
notification from a visible page; nothing while focused, one once focus
is lost, one while focused with the setting on; red on the previous
visibility check.
- `npm run typecheck --workspace @codenomad/ui`, `npm run test:help`.
2026-10-23 04:03:26 +02:00

138 lines
5 KiB
JavaScript

#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { spawnSync } = require("child_process")
const { MANAGED_NODE_VERSION } = require("./prepare-node-runtime.cjs")
const requiredPackages = [
"yaml",
"fastify",
"@fastify/static",
"@fastify/cors",
"@fastify/reply-from",
"@opencode/client",
"openai",
"pino",
"undici",
"zod",
"node-forge",
]
function parseArgs(argv) {
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === "--resources") options.resources = argv[++index]
else if (arg === "--loading") options.loading = argv[++index]
else if (arg === "--target") options.target = argv[++index]
else throw new Error(`Unknown argument: ${arg}`)
}
if (!options.resources) throw new Error("Missing --resources <path>")
return options
}
function platformDirName() {
const platform = process.platform === "darwin" ? "darwin" : process.platform
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch
return `${platform}-${arch}`
}
function targetParts(target) {
const [platform, ...archParts] = target.split("-")
const arch = archParts.join("-")
if (!platform || !arch) throw new Error(`Invalid target: ${target}`)
return { platform, arch }
}
function nodeBinary(resourcesRoot, target) {
const { platform } = targetParts(target)
const executable = platform === "win32" ? "node.exe" : path.join("bin", "node")
return path.join(resourcesRoot, "node", target, executable)
}
function canExecuteTarget(target) {
return target === platformDirName()
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, { stdio: "inherit", ...options })
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`${command} exited with code ${result.status ?? 1}`)
}
return result
}
function smokeServer(resourcesRoot, target) {
const serverRoot = path.join(resourcesRoot, "server")
const entrypoint = path.join(serverRoot, "dist", "bin.js")
// Remote Control's self-contained OpenTunnel bundle, loaded on demand.
const remoteTunnel = path.join(serverRoot, "dist", "remote-control", "tunnel-runtime.js")
// The web app's icon, which also names Tauri's notifications on Windows.
const appIcon = path.join(serverRoot, "public", "pwa-192x192.png")
const node = nodeBinary(resourcesRoot, target)
for (const requiredPath of [node, entrypoint, remoteTunnel, appIcon, path.join(serverRoot, "node_modules")]) {
if (!fs.existsSync(requiredPath)) throw new Error(`Missing packaged runtime path: ${requiredPath}`)
}
for (const packageName of requiredPackages) {
const packageRoot = path.join(serverRoot, "node_modules", ...packageName.split("/"))
if (!fs.existsSync(packageRoot)) throw new Error(`Missing packaged dependency: ${packageName}`)
}
console.log(`packaged server static checks ok for ${target}`)
if (!canExecuteTarget(target)) {
console.log(`skipping executable smoke for ${target} on host ${platformDirName()}`)
return
}
const version = run(node, ["--version"], { stdio: "pipe", encoding: "utf8" }).stdout.trim()
if (version !== MANAGED_NODE_VERSION) {
throw new Error(`Packaged Node version ${version} does not match ${MANAGED_NODE_VERSION}`)
}
console.log(`packaged Node version ok: ${version}`)
run(node, [entrypoint, "--version"])
const importScript = [
`for (const name of ${JSON.stringify(requiredPackages)}) await import(name);`,
`const tunnel = await import(${JSON.stringify(require("url").pathToFileURL(remoteTunnel).href)});`,
"if (typeof tunnel.openRemoteTunnel !== 'function') throw new Error('Remote Control tunnel runtime is incomplete');",
"console.log('packaged dependency imports ok');",
].join(" ")
// Resolve from the packaged server, not the build checkout. The V2 client is ESM-only.
run(node, ["--input-type=module", "-e", importScript], { cwd: serverRoot })
}
function smokeLoadingAssets(loadingRoot) {
if (!loadingRoot) return
const htmlPath = path.join(loadingRoot, "loading.html")
if (!fs.existsSync(htmlPath)) throw new Error(`Missing loading HTML: ${htmlPath}`)
const html = fs.readFileSync(htmlPath, "utf8")
const refs = [...html.matchAll(/(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1])
for (const ref of refs) {
if (/^(?:https?:)?\/\//.test(ref)) continue
if (!ref.startsWith("/assets/") && !ref.startsWith("assets/")) continue
const relative = ref.replace(/^\//, "")
const target = path.join(loadingRoot, relative)
if (!fs.existsSync(target)) throw new Error(`Missing loading asset referenced by ${htmlPath}: ${ref}`)
}
console.log("loading asset references ok")
}
try {
const options = parseArgs(process.argv.slice(2))
const resourcesRoot = path.resolve(options.resources)
const target = options.target ?? platformDirName()
smokeServer(resourcesRoot, target)
smokeLoadingAssets(options.loading ? path.resolve(options.loading) : null)
} catch (error) {
console.error("[smoke-packaged-resources] failed:", error)
process.exit(1)
}