From 2a0b75dd41b0937e02c66cc04871a3fd49e81e88 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Sat, 3 Oct 2026 20:41:36 -0400 Subject: [PATCH] fix(windows): stage C# sources as an exact mirror and don't ship them - App_Resources/Windows and each plugin's platforms/windows are restaged from scratch, and staged folders of uninstalled plugins are removed: C# sources there are compiled into the app, so deleted files must not keep compiling. - Generated plugin.props/plugin.targets no longer copy C# sources and MSBuild files to the app output. - Tests for staging plugin and App_Resources C# sources. --- lib/services/windows-project-service.ts | 37 ++++- test/services/windows-project-service.ts | 175 +++++++++++++++++++++++ 2 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 test/services/windows-project-service.ts diff --git a/lib/services/windows-project-service.ts b/lib/services/windows-project-service.ts index 02d217d3ae..1951213023 100644 --- a/lib/services/windows-project-service.ts +++ b/lib/services/windows-project-service.ts @@ -621,6 +621,24 @@ export class WindowsProjectService } } + // Drop the staged files of plugins that are no longer installed (their C# sources would + // otherwise still be compiled into the app). Scoped plugins stage under plugins/@scope/name. + const installedNames = new Set(installedPlugins.map((p: IPluginData) => p.name)); + for (const entry of fs.readdirSync(pluginsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const names = entry.name.startsWith("@") + ? fs + .readdirSync(path.join(pluginsDir, entry.name), { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => `${entry.name}/${e.name}`) + : [entry.name]; + for (const name of names) { + if (!installedNames.has(name)) { + this.$fs.deleteDirectory(path.join(pluginsDir, ...name.split("/"))); + } + } + } + // Write aggregate imports that the project csproj imports via "plugins\Plugins.props" const aggregatePropsPath = path.join(pluginsDir, "Plugins.props"); const aggregateTargetsPath = path.join(pluginsDir, "Plugins.targets"); @@ -700,12 +718,17 @@ export class WindowsProjectService projectData.projectName, ); - // Copy App_Resources/Windows -> platforms/windows//App_Resources/Windows + // Copy App_Resources/Windows -> platforms/windows//App_Resources/Windows. The copy is + // an exact mirror: C# sources under it are compiled into the app, so a file deleted from + // App_Resources must not linger here. const destAppResourcesWindows = path.join( platformAppDir, "App_Resources", "Windows", ); + if (this.$fs.exists(destAppResourcesWindows)) { + this.$fs.deleteDirectory(destAppResourcesWindows); + } this.$fs.ensureDirectoryExists(destAppResourcesWindows); const copyRecursive = (srcDir: string, destDir: string) => { @@ -982,6 +1005,11 @@ export class WindowsProjectService projectData.projectName, ); const pluginStageDir = path.join(appProjectDir, "plugins", pluginData.name); + // Restage from scratch: the plugin's C# sources are compiled into the app, so files a newer + // plugin version dropped must not linger. + if (this.$fs.exists(pluginStageDir)) { + this.$fs.deleteDirectory(pluginStageDir); + } this.$fs.ensureDirectoryExists(pluginStageDir); // recursively copy native files (exclude JS/TS/JSON) @@ -1028,6 +1056,10 @@ export class WindowsProjectService ); } + // Build inputs rather than files the app ships: the plugin's C# sources (staged here, + // under the app project, so the SDK's default Compile glob builds them into the app the way + // a plugin's Java/Kotlin or Objective-C/Swift sources are on Android/iOS) and MSBuild files. + const buildInputExtensions = [".cs", ".csproj", ".props", ".targets"]; const collectStagedFiles = (root: string): string[] => { const out: string[] = []; if (!this.$fs.exists(root)) return out; @@ -1035,7 +1067,8 @@ export class WindowsProjectService for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, e.name); if (e.isDirectory()) walk(full); - else out.push(path.relative(root, full).split(path.sep).join("\\")); + else if (!buildInputExtensions.includes(path.extname(e.name).toLowerCase())) + out.push(path.relative(root, full).split(path.sep).join("\\")); } }; walk(root); diff --git a/test/services/windows-project-service.ts b/test/services/windows-project-service.ts new file mode 100644 index 0000000000..df9f79ac14 --- /dev/null +++ b/test/services/windows-project-service.ts @@ -0,0 +1,175 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "chai"; +import { WindowsProjectService } from "../../lib/services/windows-project-service"; +import { injector } from "../../lib/common/yok"; +import * as stubs from "../stubs"; + +// Native sources on Windows: C# files under App_Resources/Windows and a plugin's +// platforms/windows are staged into the platform project, where the template's SDK-style csproj +// compiles them into the app (the counterpart of App_Resources/Android/src, App_Resources/iOS/src +// and plugins' platforms/android|ios sources). + +const projectName = "TestApp"; + +// The subset of IFileSystem the Windows project service uses, backed by the real file system. +const realFs = { + exists: (p: string) => fs.existsSync(p), + ensureDirectoryExists: (p: string) => fs.mkdirSync(p, { recursive: true }), + copyFile: (from: string, to: string) => { + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.copyFileSync(from, to); + }, + writeFile: (p: string, content: string) => { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content); + }, + writeJson: (p: string, value: any) => fs.writeFileSync(p, JSON.stringify(value)), + deleteDirectory: (p: string) => fs.rmSync(p, { recursive: true, force: true }), +}; + +function write(file: string, content = "") { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +describe("WindowsProjectService native sources", () => { + let root: string; + let projectDir: string; + let projectData: any; + let service: WindowsProjectService; + let appProjectDir: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "ns-windows-project-")); + projectDir = path.join(root, "app"); + fs.mkdirSync(projectDir); + projectData = { + projectDir, + projectName, + projectId: "org.nativescript.testapp", + projectIdentifiers: { windows: "org.nativescript.testapp" }, + platformsDir: path.join(projectDir, "platforms"), + nsConfig: {}, + }; + const projectDataService = { + getRuntimePackage: () => ({ name: "@nativescript/windows", version: "0.1.0" }), + }; + service = new WindowsProjectService( + realFs, + projectDataService, + {}, + new stubs.LoggerStub(), + {}, + {}, + ); + appProjectDir = path.join(projectData.platformsDir, "windows", projectName); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function plugin(name: string, files: Record) { + const fullPath = path.join(projectDir, "node_modules", ...name.split("/")); + for (const [rel, content] of Object.entries(files)) { + write(path.join(fullPath, "platforms", "windows", rel), content); + } + return { name, fullPath, nativescript: {} }; + } + + describe("preparePluginNativeCode", () => { + it("stages C# sources into the app project, where the csproj compiles them", async () => { + const p = plugin("test-plugin", { + "src/Greeter.cs": "namespace TestPlugin { public class Greeter {} }", + "src/index.js": "module.exports = {};", + "lib/Native.dll": "dll", + }); + await service.preparePluginNativeCode(p, projectData); + + const stage = path.join(appProjectDir, "plugins", "test-plugin"); + assert.isTrue(fs.existsSync(path.join(stage, "src", "Greeter.cs"))); + assert.isTrue(fs.existsSync(path.join(stage, "lib", "Native.dll"))); + assert.isFalse(fs.existsSync(path.join(stage, "src", "index.js"))); + }); + + it("does not ship C# sources with the app", async () => { + const p = plugin("test-plugin", { + "src/Greeter.cs": "namespace TestPlugin { public class Greeter {} }", + "lib/Native.dll": "dll", + }); + await service.preparePluginNativeCode(p, projectData); + + const stage = path.join(appProjectDir, "plugins", "test-plugin"); + const props = fs.readFileSync(path.join(stage, "plugin.props"), "utf8"); + const targets = fs.readFileSync(path.join(stage, "plugin.targets"), "utf8"); + assert.include(props, "lib\\Native.dll"); + assert.notInclude(props, "Greeter.cs"); + assert.include(targets, "lib\\Native.dll"); + assert.notInclude(targets, "Greeter.cs"); + }); + + it("stages scoped plugins under plugins/@scope/name", async () => { + const p = plugin("@acme/native", { "src/Acme.cs": "namespace Acme { public class A {} }" }); + await service.preparePluginNativeCode(p, projectData); + assert.isTrue(fs.existsSync(path.join(appProjectDir, "plugins", "@acme", "native", "src", "Acme.cs"))); + }); + + it("drops sources a newer plugin version no longer has", async () => { + const p = plugin("test-plugin", { "src/Old.cs": "class Old {}", "src/Kept.cs": "class Kept {}" }); + await service.preparePluginNativeCode(p, projectData); + fs.rmSync(path.join(p.fullPath, "platforms", "windows", "src", "Old.cs")); + await service.preparePluginNativeCode(p, projectData); + + const stage = path.join(appProjectDir, "plugins", "test-plugin", "src"); + assert.isFalse(fs.existsSync(path.join(stage, "Old.cs"))); + assert.isTrue(fs.existsSync(path.join(stage, "Kept.cs"))); + }); + }); + + describe("prepareAppResources", () => { + it("mirrors App_Resources/Windows, C# sources included", () => { + write(path.join(projectDir, "App_Resources", "Windows", "src", "Fixtures.cs"), "class Fixtures {}"); + write(path.join(projectDir, "App_Resources", "Windows", "app.csproj"), ""); + service.prepareAppResources(projectData); + + const dest = path.join(appProjectDir, "App_Resources", "Windows"); + assert.isTrue(fs.existsSync(path.join(dest, "src", "Fixtures.cs"))); + assert.isTrue(fs.existsSync(path.join(dest, "app.csproj"))); + }); + + it("removes C# sources deleted from App_Resources/Windows", () => { + const src = path.join(projectDir, "App_Resources", "Windows", "src"); + write(path.join(src, "Gone.cs"), "class Gone {}"); + write(path.join(src, "Stays.cs"), "class Stays {}"); + service.prepareAppResources(projectData); + fs.rmSync(path.join(src, "Gone.cs")); + service.prepareAppResources(projectData); + + const dest = path.join(appProjectDir, "App_Resources", "Windows", "src"); + assert.isFalse(fs.existsSync(path.join(dest, "Gone.cs"))); + assert.isTrue(fs.existsSync(path.join(dest, "Stays.cs"))); + }); + }); + + describe("prepareProject", () => { + it("removes the staged sources of uninstalled plugins", async () => { + const kept = plugin("kept-plugin", { "src/Kept.cs": "class Kept {}" }); + const scoped = plugin("@acme/kept", { "src/Acme.cs": "class Acme {}" }); + write(path.join(appProjectDir, "plugins", "removed-plugin", "src", "Removed.cs"), "class Removed {}"); + write(path.join(appProjectDir, "plugins", "@acme", "removed", "src", "Gone.cs"), "class Gone {}"); + + injector.register("pluginsService", { getAllInstalledPlugins: async () => [kept, scoped] }); + await service.prepareProject(projectData, {}); + + const plugins = path.join(appProjectDir, "plugins"); + assert.isTrue(fs.existsSync(path.join(plugins, "kept-plugin", "src", "Kept.cs"))); + assert.isTrue(fs.existsSync(path.join(plugins, "@acme", "kept", "src", "Acme.cs"))); + assert.isFalse(fs.existsSync(path.join(plugins, "removed-plugin"))); + assert.isFalse(fs.existsSync(path.join(plugins, "@acme", "removed"))); + const aggregate = fs.readFileSync(path.join(plugins, "Plugins.props"), "utf8"); + assert.include(aggregate, "kept-plugin\\plugin.props"); + }); + }); +});