diff --git a/docs/man_pages/project/testing/build-linux.md b/docs/man_pages/project/testing/build-linux.md new file mode 100644 index 0000000000..b2283f53ce --- /dev/null +++ b/docs/man_pages/project/testing/build-linux.md @@ -0,0 +1,36 @@ +<% if (isJekyll) { %>--- +title: ns build linux +position: 12 +---<% } %> + +# ns build linux + +### Description + +Builds the project for Linux. The output is a folder with the runtime executable (named after the project), its metadata and the bundled app; `--release` also packs it as a `.tar.gz`. + +<% if(isConsole && (isWindows || isMacOS)) { %>WARNING: You can run this command only on Linux systems. To view the complete help for this command, run `$ ns help build linux`<% } %> + +### Commands + +Usage | Synopsis +---|--- +Build for Linux | `$ ns build linux [--release] [--arch ] [--env.*]` + +### Options + +* `--release` - If set, produces a release build in `platforms/linux//dist`. Otherwise, produces a debug build in `platforms/linux//bin`. +* `--arch` - The runtime architecture to package, `x64` or `arm64`. Defaults to the host architecture. +* `--env.*` - Specifies additional flags that the bundler may process. Can be passed multiple times. +* `--force` - If set, skips the application compatibility checks and forces `npm i` to ensure all dependencies are installed. + +<% if(isHtml) { %> + +### Related Commands + +Command | Description +----------|---------- +[run linux](run-linux.html) | Runs your project on the local Linux machine. +[debug linux](debug-linux.html) | Debugs your project on the local Linux machine. +[build](build.html) | Builds the project for the selected target platform. +<% } %> diff --git a/docs/man_pages/project/testing/build.md b/docs/man_pages/project/testing/build.md index 587cd4605c..9f66f8e496 100644 --- a/docs/man_pages/project/testing/build.md +++ b/docs/man_pages/project/testing/build.md @@ -25,7 +25,8 @@ Usage | Synopsis `` is the target platform for which you want to build your project. You can set the following target platforms. * `android` - Build the project for Android and produces an `APK` that you can manually deploy on a device or in the native emulator. * `ios` - Build the project for iOS and produces an `APP` or `IPA` that you can manually deploy in the iOS Simulator or on a device. -* `windows` - Build the project for Windows. Release builds produce an `MSIX` package (Windows only).<% } %> +* `windows` - Build the project for Windows. Release builds produce an `MSIX` package (Windows only). +* `linux` - Build the project for Linux. Release builds produce a `.tar.gz` (Linux only).<% } %> ### Options @@ -55,6 +56,7 @@ Command | Description [build android](build-android.html) | Builds the project for Android and produces an APK that you can manually deploy on device or in the native emulator. [build ios](build-ios.html) | Builds the project for iOS and produces an APP or IPA that you can manually deploy in the iOS Simulator or on device, respectively. [build windows](build-windows.html) | Builds the project for Windows. +[build linux](build-linux.html) | Builds the project for Linux. [debug android](debug-android.html) | Debugs your project on a connected Android device or in a native emulator. [debug ios](debug-ios.html) | Debugs your project on a connected iOS device or in a native emulator. [debug](debug.html) | Debugs your project on a connected device or in a native emulator. diff --git a/docs/man_pages/project/testing/debug-linux.md b/docs/man_pages/project/testing/debug-linux.md new file mode 100644 index 0000000000..2a5ed09985 --- /dev/null +++ b/docs/man_pages/project/testing/debug-linux.md @@ -0,0 +1,37 @@ +<% if (isJekyll) { %>--- +title: ns debug linux +position: 12 +---<% } %> + +# ns debug linux + +### Description + +Runs your project on the local Linux machine with the Chrome DevTools inspector enabled (port 9229, or `$NS_LINUX_INSPECTOR_PORT`) and prints the DevTools URL. + +<% if(isConsole && (isWindows || isMacOS)) { %>WARNING: You can run this command only on Linux systems. To view the complete help for this command, run `$ ns help debug linux`<% } %> + +### Commands + +Usage | Synopsis +---|--- +Debug on the local Linux machine | `$ ns debug linux [--debug-brk] [--start] [--timeout ] [--env.*]` + +### Options + +* `--debug-brk` - Stops on the first line of the app until DevTools attaches. +* `--start` - Attaches to the already running app instead of starting it. +* `--timeout` - Seconds to wait for the inspector. Defaults to 90. +* `--no-hmr` - Disables Hot Module Replacement (HMR). +* `--env.*` - Specifies additional flags that the bundler may process. Can be passed multiple times. + +<% if(isHtml) { %> + +### Related Commands + +Command | Description +----------|---------- +[run linux](run-linux.html) | Runs your project on the local Linux machine. +[build linux](build-linux.html) | Builds the project for Linux. +[debug](debug.html) | Debugs your project on a connected device or in a native emulator. +<% } %> diff --git a/docs/man_pages/project/testing/debug.md b/docs/man_pages/project/testing/debug.md index b428bc0f82..2643eddba3 100644 --- a/docs/man_pages/project/testing/debug.md +++ b/docs/man_pages/project/testing/debug.md @@ -41,7 +41,8 @@ Usage | Synopsis `` is the target platform for which you want to debug your project. You can set the following target platforms: * `android` - Start a debugging session for your project on a connected Android device or Android emulator. * `ios` - Start a debugging session for your project on a connected iOS device or in the native iOS simulator. -* `windows` - Start a debugging session on the local Windows machine via the Chrome DevTools Protocol on port 43000 (Windows only).<% } %> +* `windows` - Start a debugging session on the local Windows machine via the Chrome DevTools Protocol on port 43000 (Windows only). +* `linux` - Start a debugging session on the local Linux machine via the Chrome DevTools Protocol on port 9229 (Linux only).<% } %> <% if(isHtml) { %> @@ -60,6 +61,7 @@ Command | Description [debug android](debug-android.html) | Debugs your project on a connected Android device or in a native emulator. [debug ios](debug-ios.html) | Debugs your project on a connected iOS device or in a native emulator. [debug windows](debug-windows.html) | Debugs your project on the local Windows machine. +[debug linux](debug-linux.html) | Debugs your project on the local Linux machine. [deploy](deploy.html) | Builds and deploys the project to a connected physical or virtual device. [run android](run-android.html) | Runs your project on a connected Android device or in a native Android emulator, if configured. [run ios](run-ios.html) | Runs your project on a connected iOS device or in the iOS Simulator, if configured. diff --git a/docs/man_pages/project/testing/run-linux.md b/docs/man_pages/project/testing/run-linux.md new file mode 100644 index 0000000000..2db80499a5 --- /dev/null +++ b/docs/man_pages/project/testing/run-linux.md @@ -0,0 +1,49 @@ +<% if (isJekyll) { %>--- +title: ns run linux +position: 12 +---<% } %> + +# ns run linux + +### Description + +Runs your project on the local Linux machine. This is shorthand for prepare, build, deploy and launch. While your app is running, prints the output from the application in the console and watches for changes in your code. Once a change is detected, it synchronizes the change with the running application. + +<% if(isConsole && (isWindows || isMacOS)) { %>WARNING: You can run this command only on Linux systems. To view the complete help for this command, run `$ ns help run linux`<% } %> +<% if((isConsole && isLinux) || isHtml) { %> +The app runs as a child process of the CLI. To start it with the Chrome DevTools inspector enabled, use `$ ns debug linux`. +<% } %> + +### Commands + +Usage | Synopsis +---|--- +Run on the local Linux machine | `$ ns run linux [--release] [--justlaunch] [--arch ] [--env.*]` + +### Options + +* `--justlaunch` - If set, does not print the application output in the console. +* `--release` - If set, produces a release build. Otherwise, produces a debug build. +* `--arch` - The runtime architecture to package, `x64` or `arm64`. Defaults to the host architecture. +* `--no-hmr` - Disables Hot Module Replacement (HMR). When a change in the code is applied, CLI will transfer the modified files and restart the application. +* `--env.*` - Specifies additional flags that the bundler may process. Can be passed multiple times. +* `--force` - If set, skips the application compatibility checks and forces `npm i` to ensure all dependencies are installed. + +<% if(isHtml) { %> + +### Prerequisites + +* A Linux distribution with GTK 4 installed (for example `sudo apt install libgtk-4-1` or `sudo dnf install gtk4`). + +### Command Limitations + +* You can run `$ ns run linux` only on Linux systems. + +### Related Commands + +Command | Description +----------|---------- +[build linux](build-linux.html) | Builds the project for Linux. +[debug linux](debug-linux.html) | Debugs your project on the local Linux machine. +[run](run.html) | Runs your project on a connected device or in the native emulator for the selected platform. +<% } %> diff --git a/docs/man_pages/project/testing/run.md b/docs/man_pages/project/testing/run.md index 039bed446e..73e5677ac1 100644 --- a/docs/man_pages/project/testing/run.md +++ b/docs/man_pages/project/testing/run.md @@ -62,6 +62,7 @@ Run on a selected connected device or running emulator. Will start emulator with * `android` - Run your project on all Android devices and emulators. * `ios` - Run your project on all iOS devices and simulators. * `windows` - Run your project on the local Windows machine (Windows only). + * `linux` - Run your project on the local Linux machine (Linux only). <% } %> @@ -89,6 +90,7 @@ Command | Description [run android](run-android.html) | Runs your project on a connected Android device or in a native Android emulator, if configured. [run ios](run-ios.html) | Runs your project on a connected iOS device or in the iOS Simulator, if configured. [run windows](run-windows.html) | Runs your project on the local Windows machine. +[run linux](run-linux.html) | Runs your project on the local Linux machine. [test init](test-init.html) | Configures your project for unit testing with a selected framework. [test android](test-android.html) | Runs the tests in your project on Android devices or native emulators. [test ios](test-ios.html) | Runs the tests in your project on iOS devices or the iOS Simulator. diff --git a/docs/man_pages/project/testing/typings.md b/docs/man_pages/project/testing/typings.md index ab14676511..8fc883795a 100644 --- a/docs/man_pages/project/testing/typings.md +++ b/docs/man_pages/project/testing/typings.md @@ -15,6 +15,7 @@ Usage | Synopsis ---|--- Generate typings to Android | `$ ns typings android [--jar --jar ] [--aar ] [--copy-to ]` Generate typings to iOS | `$ ns typings ios [--filter ] [--copy-to ]` +Generate typings for Linux native code | `$ ns typings linux [--copy-to ]` ### Options @@ -28,6 +29,7 @@ Generate typings to iOS | `$ ns typings ios [--filter ] [--copy-to ` is the target mobile platform for which you want to generate typings. You can set the following target platforms: * `android` - Generate typings for android. * `ios` - Generate typings for iOS. + * `linux` - Generate typings for the C code in `App_Resources/Linux` and plugins' `platforms/linux` into `typings/linux`. Later builds keep them up to date. diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 8b4d3e1319..b4b4444d0a 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -57,6 +57,7 @@ injector.require("iOSExtensionsService", "./services/ios-extensions-service"); injector.require("iOSWatchAppService", "./services/ios-watch-app-service"); injector.require("iOSProjectService", "./services/ios-project-service"); injector.require("windowsProjectService", "./services/windows-project-service"); +injector.require("linuxProjectService", "./services/linux-project-service"); injector.require("iOSProvisionService", "./services/ios-provision-service"); injector.require("xcconfigService", "./services/xcconfig-service"); injector.require("iOSSigningService", "./services/ios/ios-signing-service"); @@ -157,6 +158,10 @@ injector.require( "windowsDeviceDebugService", "./services/windows-device-debug-service", ); +injector.require( + "linuxDeviceDebugService", + "./services/linux-device-debug-service", +); injector.require( "timelineProfilerService", @@ -189,6 +194,7 @@ injector.requireCommand("run|android", "./commands/run"); injector.requireCommand("run|vision", "./commands/run"); injector.requireCommand("run|visionos", "./commands/run"); injector.requireCommand("run|windows", "./commands/run"); +injector.requireCommand("run|linux", "./commands/run"); injector.requireCommand("typings", "./commands/typings"); injector.requireCommand("preview", "./commands/preview"); @@ -198,6 +204,7 @@ injector.requireCommand("debug|android", "./commands/debug"); injector.requireCommand("debug|vision", "./commands/debug"); injector.requireCommand("debug|visionos", "./commands/debug"); injector.requireCommand("debug|windows", "./commands/debug"); +injector.requireCommand("debug|linux", "./commands/debug"); injector.requireCommand("fonts", "./commands/fonts"); injector.requireCommand("prepare", "./commands/prepare"); @@ -206,6 +213,7 @@ injector.requireCommand("build|android", "./commands/build"); injector.requireCommand("build|vision", "./commands/build"); injector.requireCommand("build|visionos", "./commands/build"); injector.requireCommand("build|windows", "./commands/build"); +injector.requireCommand("build|linux", "./commands/build"); injector.requireCommand("deploy", "./commands/deploy"); injector.requireCommand("embed", "./commands/embedding/embed"); @@ -334,6 +342,10 @@ injector.require( "windowsLiveSyncService", "./services/livesync/windows-livesync-service", ); +injector.require( + "linuxLiveSyncService", + "./services/livesync/linux-livesync-service", +); injector.require("usbLiveSyncService", "./services/livesync/livesync-service"); // The name is used in https://github.com/NativeScript/nativescript-dev-typescript injector.requirePublic("sysInfo", "./sys-info"); diff --git a/lib/commands/build.ts b/lib/commands/build.ts index ca125c6261..5c5e2bee30 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -349,3 +349,54 @@ export class BuildWindowsCommand extends BuildCommandBase implements ICommand { } injector.registerCommand("build|windows", BuildWindowsCommand); + +export class BuildLinuxCommand extends BuildCommandBase implements ICommand { + public allowedParameters: ICommandParameter[] = []; + + constructor( + protected $options: IOptions, + $errors: IErrors, + $projectData: IProjectData, + $platformsDataService: IPlatformsDataService, + protected $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, + $buildController: IBuildController, + $platformValidationService: IPlatformValidationService, + $buildDataService: IBuildDataService, + protected $logger: ILogger, + private $migrateController: IMigrateController, + ) { + super( + $options, + $errors, + $projectData, + $platformsDataService, + $devicePlatformsConstants, + $buildController, + $platformValidationService, + $buildDataService, + $logger, + ); + } + + public async execute(_args: string[]): Promise { + await this.executeCore([ + this.$devicePlatformsConstants.Linux.toLowerCase(), + ]); + } + + public async canExecute(args: string[]): Promise { + const platform = this.$devicePlatformsConstants.Linux; + if (!this.$options.force) { + await this.$migrateController.validate({ + projectDir: this.$projectData.projectDir, + platforms: [platform], + }); + } + return ( + (await super.canExecuteCommandBase(platform)) && + super.validateArgs(args, platform) + ); + } +} + +injector.registerCommand("build|linux", BuildLinuxCommand); diff --git a/lib/commands/debug.ts b/lib/commands/debug.ts index 108c0bbbdd..929dc7ea7f 100644 --- a/lib/commands/debug.ts +++ b/lib/commands/debug.ts @@ -281,3 +281,35 @@ export class DebugWindowsCommand implements ICommand { } injector.registerCommand("debug|windows", DebugWindowsCommand); + +export class DebugLinuxCommand implements ICommand { + @cache() + private get debugPlatformCommand(): DebugPlatformCommand { + return this.$injector.resolve(DebugPlatformCommand, { + platform: this.$devicePlatformsConstants.Linux, + }); + } + + public allowedParameters: ICommandParameter[] = []; + + constructor( + protected $errors: IErrors, + private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, + private $injector: IInjector, + private $projectData: IProjectData, + ) { + this.$projectData.initializeProjectData(); + } + + public async execute(args: string[]): Promise { + return this.debugPlatformCommand.execute(args); + } + + public async canExecute(args: string[]): Promise { + return this.debugPlatformCommand.canExecute(args); + } + + public platform = this.$devicePlatformsConstants.Linux; +} + +injector.registerCommand("debug|linux", DebugLinuxCommand); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 800b99a79f..7fad36b07f 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -274,3 +274,52 @@ export class RunWindowsCommand implements ICommand { } injector.registerCommand("run|windows", RunWindowsCommand); + +export class RunLinuxCommand implements ICommand { + @cache() + private get runCommand(): RunCommandBase { + const runCommand = this.$injector.resolve(RunCommandBase); + runCommand.platform = this.platform; + return runCommand; + } + + public allowedParameters: ICommandParameter[] = []; + public get platform(): string { + return this.$devicePlatformsConstants.Linux; + } + + constructor( + private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, + private $errors: IErrors, + private $injector: IInjector, + private $options: IOptions, + private $platformValidationService: IPlatformValidationService, + private $projectData: IProjectData, + ) {} + + public async execute(args: string[]): Promise { + return this.runCommand.execute(args); + } + + public async canExecute(args: string[]): Promise { + await this.runCommand.canExecute(args); + if ( + !this.$platformValidationService.isPlatformSupportedForOS( + this.platform, + this.$projectData, + ) + ) { + this.$errors.fail( + `Applications for platform ${this.platform} can not be built on this OS`, + ); + } + return this.$platformValidationService.validateOptions( + this.$options.provision, + this.$options.teamId, + this.$projectData, + this.platform.toLowerCase(), + ); + } +} + +injector.registerCommand("run|linux", RunLinuxCommand); diff --git a/lib/commands/typings.ts b/lib/commands/typings.ts index 9ec212872d..78110fdb5b 100644 --- a/lib/commands/typings.ts +++ b/lib/commands/typings.ts @@ -1,5 +1,6 @@ import { glob } from "node:fs/promises"; import { homedir } from "os"; +import * as fs from "fs"; import * as path from "path"; import { PromptObject } from "prompts"; import { color } from "../color"; @@ -8,6 +9,7 @@ import { ICommand, ICommandParameter } from "../common/definitions/commands"; import { injector } from "../common/yok"; import { IOptions, IStaticConfig } from "../declarations"; import { IProjectData } from "../definitions/project"; +import { writeTypingsIndex } from "../services/linux-native-code"; export class TypingsCommand implements ICommand { public allowedParameters: ICommandParameter[] = []; @@ -32,6 +34,8 @@ export class TypingsCommand implements ICommand { result = await this.handleiOSTypings(); } else if (this.$mobileHelper.isWindowsPlatform(platform)) { result = await this.handleWindowsTypings(); + } else if (this.$mobileHelper.isLinuxPlatform(platform)) { + result = await this.handleLinuxTypings(); } let typingsFolder = "./typings"; if (this.$options.copyTo) { @@ -283,9 +287,51 @@ export class TypingsCommand implements ICommand { generatorArgs.push("--libs", this.$options.libs); } - await this.$childProcess.spawnFromEvent(generatorPath, generatorArgs, "exit", { - stdio: "inherit", - }); + await this.$childProcess.spawnFromEvent( + generatorPath, + generatorArgs, + "exit", + { + stdio: "inherit", + }, + ); + } + + /** Declarations for the C APIs of App_Resources/Linux and plugin native code. */ + private async handleLinuxTypings() { + if (!this.$hostInfo.isLinux) { + this.$logger.warn("Linux typings can only be generated on Linux."); + return false; + } + const outDir = path.resolve( + this.$projectData.projectDir, + "typings", + "linux", + ); + this.$fs.ensureDirectoryExists(outDir); + const linuxProjectService: any = injector.resolve("linuxProjectService"); + const stage = path.join( + linuxProjectService.getPlatformData(this.$projectData).projectRoot, + "build", + "typings-stage", + ); + await linuxProjectService.buildNativeCode( + this.$projectData, + stage, + false, + outDir, + ); + this.$fs.deleteDirectory(stage); + const sdk = linuxProjectService.sdkTypingsDir(this.$projectData); + if (this.$fs.exists(sdk)) { + const target = path.join(outDir, "sdk"); + this.$fs.deleteDirectory(target); + fs.cpSync(sdk, target, { recursive: true }); + } + writeTypingsIndex(outDir); + this.$logger.info( + `Reference them with /// `, + ); } private async handleiOSTypings() { diff --git a/lib/common/bootstrap.ts b/lib/common/bootstrap.ts index 9ff391047d..aded4d0d66 100644 --- a/lib/common/bootstrap.ts +++ b/lib/common/bootstrap.ts @@ -89,6 +89,10 @@ injector.require( "windowsDeviceDiscovery", "./mobile/windows/windows-device-discovery", ); +injector.require( + "linuxDeviceDiscovery", + "./mobile/linux/linux-device-discovery", +); injector.require( "androidEmulatorDiscovery", "./mobile/mobile-core/android-emulator-discovery", diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index 5b097c1e1f..49fddf0beb 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -1113,6 +1113,7 @@ declare global { isiOSPlatform(platform: string): boolean; isvisionOSPlatform(platform: string): boolean; isWindowsPlatform(platform: string): boolean; + isLinuxPlatform(platform: string): boolean; isApplePlatform(platform: string): boolean; normalizePlatformName(platform: string): string; validatePlatformName(platform: string): string; @@ -1158,11 +1159,13 @@ declare global { Android: string; visionOS: string; Windows: string; + Linux: string; isiOS(value: string): boolean; isAndroid(value: string): boolean; isvisionOS(value: string): boolean; isWindows(value: string): boolean; + isLinux(value: string): boolean; } interface IDeviceApplication { diff --git a/lib/common/mobile/device-platforms-constants.ts b/lib/common/mobile/device-platforms-constants.ts index 19f1ced9ff..d8b274d342 100644 --- a/lib/common/mobile/device-platforms-constants.ts +++ b/lib/common/mobile/device-platforms-constants.ts @@ -7,6 +7,7 @@ export class DevicePlatformsConstants public Android = "Android"; public visionOS = "visionOS"; public Windows = "Windows"; + public Linux = "Linux"; public isiOS(value: string) { return value.toLowerCase() === this.iOS.toLowerCase(); @@ -23,5 +24,9 @@ export class DevicePlatformsConstants public isWindows(value: string) { return value.toLowerCase() === this.Windows.toLowerCase(); } + + public isLinux(value: string) { + return value.toLowerCase() === this.Linux.toLowerCase(); + } } injector.register("devicePlatformsConstants", DevicePlatformsConstants); diff --git a/lib/common/mobile/linux/linux-application-manager.ts b/lib/common/mobile/linux/linux-application-manager.ts new file mode 100644 index 0000000000..9d408bee9f --- /dev/null +++ b/lib/common/mobile/linux/linux-application-manager.ts @@ -0,0 +1,182 @@ +import * as fs from "fs"; +import * as path from "path"; +import { ChildProcess, spawn } from "child_process"; +import { ApplicationManagerBase } from "../application-manager-base"; +import { IHooksService, IDictionary } from "../../declarations"; +import { IBuildData } from "../../../definitions/build"; + +/** Port of the runtime's DevTools inspector for `ns debug linux`. */ +export const LINUX_INSPECTOR_PORT = 9229; + +/** + * Runs the built app (the runtime executable next to `app/`) as a child process. + * Logs come from its stdout/stderr; SIGUSR1 makes the runtime call `global.__onLiveSync()`. + */ +export class LinuxApplicationManager extends ApplicationManagerBase { + private _executables: IDictionary = {}; + private _process: ChildProcess | null = null; + + constructor( + $logger: ILogger, + $hooksService: IHooksService, + $deviceLogProvider: Mobile.IDeviceLogProvider, + private deviceIdentifier: string, + ) { + super($logger, $hooksService, $deviceLogProvider); + process.once("exit", () => this.kill()); + } + + public get inspectorPort(): number { + return ( + parseInt(process.env.NS_LINUX_INSPECTOR_PORT, 10) || LINUX_INSPECTOR_PORT + ); + } + + public async getInstalledApplications(): Promise { + return Object.keys(this._executables); + } + + public async isApplicationInstalled(appIdentifier: string): Promise { + return !!this.executableFor({ + appId: appIdentifier, + projectName: undefined, + }); + } + + /** `packageFilePath` is the executable in the build output. */ + public async installApplication( + packageFilePath: string, + appIdentifier?: string, + _buildData?: IBuildData, + ): Promise { + fs.chmodSync(packageFilePath, 0o755); + this._executables[path.basename(packageFilePath)] = packageFilePath; + if (appIdentifier) { + this._executables[appIdentifier] = packageFilePath; + } + } + + public async uninstallApplication(appIdentifier: string): Promise { + delete this._executables[appIdentifier]; + } + + public async startApplication( + appData: Mobile.IStartApplicationData, + ): Promise { + const executable = this.executableFor(appData); + if (!executable) { + throw new Error( + `${appData.projectName || appData.appId} is not built for Linux. Run \`ns build linux\` first.`, + ); + } + this.kill(); + + const args: string[] = []; + if (appData.waitForDebugger) { + args.push(`--inspect-brk=${this.inspectorPort}`); + } else if (appData.debugMode) { + args.push(`--inspect=${this.inspectorPort}`); + } + const child = spawn(executable, args, { + cwd: path.dirname(executable), + stdio: ["ignore", "pipe", "pipe"], + }); + this._process = child; + this.$logger.trace( + `[Linux] started ${executable} ${args.join(" ")} (pid ${child.pid})`, + ); + + const forward = (stream: NodeJS.ReadableStream) => { + let pending = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk: string) => { + pending += chunk; + const lines = pending.split("\n"); + pending = lines.pop(); + for (const line of lines) { + this.$deviceLogProvider.logData(line, "Linux", this.deviceIdentifier); + } + }); + stream.on("end", () => { + if (pending) { + this.$deviceLogProvider.logData( + pending, + "Linux", + this.deviceIdentifier, + ); + } + }); + }; + forward(child.stdout); + forward(child.stderr); + child.once("exit", (code, signal) => { + if (this._process === child) { + this._process = null; + } + this.$logger.trace(`[Linux] app exited (code ${code}, signal ${signal})`); + }); + } + + public async stopApplication( + _appData: Mobile.IApplicationData, + ): Promise { + const child = this._process; + if (!child) { + return; + } + const exited = new Promise((resolve) => + child.once("exit", () => resolve()), + ); + this.kill(); + await Promise.race([ + exited, + new Promise((resolve) => setTimeout(resolve, 3000)), + ]); + } + + public async restartApplication( + appData: Mobile.IStartApplicationData, + ): Promise { + await this.stopApplication(appData); + await this.startApplication(appData); + } + + public isRunning(): boolean { + return !!this._process && this._process.exitCode === null; + } + + /** Asks the running app to apply synced changes (webpack HMR). */ + public signalLiveSync(): boolean { + if (!this.isRunning()) { + return false; + } + return this._process.kill("SIGUSR1"); + } + + public async getDebuggableApps(): Promise< + Mobile.IDeviceApplicationInformation[] + > { + return []; + } + + public async getDebuggableAppViews( + _appIdentifiers: string[], + ): Promise> { + return {}; + } + + private executableFor( + appData: Pick, + ): string | null { + const executable = + (appData.appId && this._executables[appData.appId]) || + (appData.projectName && this._executables[appData.projectName]); + return executable && fs.existsSync(executable) ? executable : null; + } + + private kill(): void { + if (this.isRunning()) { + this._process.kill("SIGTERM"); + } + } +} diff --git a/lib/common/mobile/linux/linux-device-discovery.ts b/lib/common/mobile/linux/linux-device-discovery.ts new file mode 100644 index 0000000000..ccb4e0f2c4 --- /dev/null +++ b/lib/common/mobile/linux/linux-device-discovery.ts @@ -0,0 +1,36 @@ +import { DeviceDiscovery } from "../mobile-core/device-discovery"; +import { LinuxDevice } from "./linux-device"; +import { IInjector } from "../../definitions/yok"; +import { injector } from "../../yok"; + +export class LinuxDeviceDiscovery + extends DeviceDiscovery + implements Mobile.IDeviceDiscovery +{ + private _deviceAdded = false; + + constructor( + private $injector: IInjector, + private $mobileHelper: Mobile.IMobileHelper, + ) { + super(); + } + + public async startLookingForDevices( + options?: Mobile.IDeviceLookingOptions, + ): Promise { + if ( + options?.platform && + !this.$mobileHelper.isLinuxPlatform(options.platform) + ) { + return; + } + + if (!this._deviceAdded) { + this._deviceAdded = true; + const device = this.$injector.resolve(LinuxDevice); + this.addDevice(device); + } + } +} +injector.register("linuxDeviceDiscovery", LinuxDeviceDiscovery); diff --git a/lib/common/mobile/linux/linux-device-file-system.ts b/lib/common/mobile/linux/linux-device-file-system.ts new file mode 100644 index 0000000000..e91f9d1506 --- /dev/null +++ b/lib/common/mobile/linux/linux-device-file-system.ts @@ -0,0 +1,116 @@ +import * as fs from "fs"; +import * as path from "path"; +import { IStringDictionary } from "../../declarations"; + +export class LinuxDeviceFileSystem implements Mobile.IDeviceFileSystem { + public async listFiles(_devicePath: string): Promise { + // no-op for local desktop + } + + public async getFile( + deviceFilePath: string, + _appIdentifier: string, + outputFilePath?: string, + ): Promise { + if (outputFilePath) { + fs.copyFileSync(deviceFilePath, outputFilePath); + } + } + + public async getFileContent( + deviceFilePath: string, + _appIdentifier: string, + ): Promise { + return fs.existsSync(deviceFilePath) + ? fs.readFileSync(deviceFilePath, "utf8") + : null; + } + + public async putFile( + localFilePath: string, + deviceFilePath: string, + _appIdentifier: string, + ): Promise { + fs.mkdirSync(path.dirname(deviceFilePath), { recursive: true }); + fs.copyFileSync(localFilePath, deviceFilePath); + } + + public async deleteFile( + deviceFilePath: string, + _appIdentifier: string, + ): Promise { + if (fs.existsSync(deviceFilePath)) { + fs.unlinkSync(deviceFilePath); + } + } + + public async transferFiles( + _deviceAppData: Mobile.IDeviceAppData, + localToDevicePaths: Mobile.ILocalToDevicePathData[], + ): Promise { + this._syncPaths(localToDevicePaths); + return localToDevicePaths; + } + + public async transferDirectory( + _deviceAppData: Mobile.IDeviceAppData, + localToDevicePaths: Mobile.ILocalToDevicePathData[], + _projectFilesPath: string, + ): Promise { + this._syncPaths(localToDevicePaths); + return localToDevicePaths; + } + + private _syncPaths( + localToDevicePaths: Mobile.ILocalToDevicePathData[], + ): void { + for (const pathData of localToDevicePaths) { + const src = pathData.getLocalPath(); + const dest = pathData.getDevicePath(); + if (src === dest) { + continue; + } + try { + const stat = fs.statSync(src); + if (stat.isDirectory()) { + fs.mkdirSync(dest, { recursive: true }); + } else { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + } + } catch { + // skip entries that disappeared between detection and sync + } + } + } + + public async transferFile( + localFilePath: string, + deviceFilePath: string, + ): Promise { + fs.mkdirSync(path.dirname(deviceFilePath), { recursive: true }); + fs.copyFileSync(localFilePath, deviceFilePath); + } + + public async createFileOnDevice( + deviceFilePath: string, + fileContent: string, + ): Promise { + fs.mkdirSync(path.dirname(deviceFilePath), { recursive: true }); + fs.writeFileSync(deviceFilePath, fileContent, "utf8"); + } + + public async updateHashesOnDevice( + _hashes: IStringDictionary, + _appIdentifier: string, + ): Promise { + // no-op: Linux LiveSync does not use hash-based diffing yet + } + + public getDeviceHashService(_appIdentifier: string): any { + return { + generateHashesFromLocalToDevicePaths: async (_paths: any[]) => ({}), + removeHashes: async (_paths: any[]) => {}, + }; + } +} diff --git a/lib/common/mobile/linux/linux-device.ts b/lib/common/mobile/linux/linux-device.ts new file mode 100644 index 0000000000..4acb85409f --- /dev/null +++ b/lib/common/mobile/linux/linux-device.ts @@ -0,0 +1,50 @@ +import * as os from "os"; +import { DeviceConnectionType } from "../../../constants"; +import { CONNECTED_STATUS, DeviceTypes } from "../../constants"; +import { LinuxApplicationManager } from "./linux-application-manager"; +import { LinuxDeviceFileSystem } from "./linux-device-file-system"; +import { IHooksService } from "../../declarations"; + +/** The local machine: apps run as a child process of the CLI. */ +export class LinuxDevice implements Mobile.IDevice { + public applicationManager: Mobile.IDeviceApplicationManager; + public fileSystem: Mobile.IDeviceFileSystem; + public readonly isEmulator = false; + public readonly isOnlyWiFiConnected = false; + + public readonly deviceInfo: Mobile.IDeviceInfo = { + identifier: os.hostname(), + displayName: `${os.hostname()} (Linux ${os.release()})`, + model: os.arch(), + version: os.release(), + vendor: "Linux", + status: CONNECTED_STATUS, + errorHelp: null, + isTablet: false, + type: DeviceTypes.Device, + platform: "Linux", + connectionTypes: [DeviceConnectionType.Local], + }; + + constructor( + $logger: ILogger, + $hooksService: IHooksService, + $deviceLogProvider: Mobile.IDeviceLogProvider, + ) { + this.applicationManager = new LinuxApplicationManager( + $logger, + $hooksService, + $deviceLogProvider, + this.deviceInfo.identifier, + ); + this.fileSystem = new LinuxDeviceFileSystem(); + } + + public async openDeviceLogStream(): Promise { + // logs are forwarded from the app process by the application manager + } + + public detach(): void { + // nothing to release + } +} diff --git a/lib/common/mobile/mobile-core/devices-service.ts b/lib/common/mobile/mobile-core/devices-service.ts index 64cf77668a..b5bee335ef 100644 --- a/lib/common/mobile/mobile-core/devices-service.ts +++ b/lib/common/mobile/mobile-core/devices-service.ts @@ -43,6 +43,7 @@ export class DevicesService private $iOSDeviceDiscovery: Mobile.IDeviceDiscovery, private $androidDeviceDiscovery: Mobile.IDeviceDiscovery, private $windowsDeviceDiscovery: Mobile.IDeviceDiscovery, + private $linuxDeviceDiscovery: Mobile.IDeviceDiscovery, private $staticConfig: Config.IStaticConfig, private $messages: IMessages, private $mobileHelper: Mobile.IMobileHelper, @@ -66,6 +67,7 @@ export class DevicesService this.$androidDeviceDiscovery, this.$iOSSimulatorDiscovery, this.$windowsDeviceDiscovery, + this.$linuxDeviceDiscovery, ]; } @@ -327,6 +329,7 @@ export class DevicesService this.$iOSDeviceDiscovery, this.$androidDeviceDiscovery, this.$windowsDeviceDiscovery, + this.$linuxDeviceDiscovery, ].forEach(this.attachToDeviceDiscoveryEvents.bind(this)); } @@ -1154,6 +1157,10 @@ export class DevicesService await this.$windowsDeviceDiscovery.startLookingForDevices( deviceLookingOptions, ); + } else if (this.$mobileHelper.isLinuxPlatform(platform)) { + await this.$linuxDeviceDiscovery.startLookingForDevices( + deviceLookingOptions, + ); } } diff --git a/lib/common/mobile/mobile-helper.ts b/lib/common/mobile/mobile-helper.ts index 5b5ee33de8..de6e6e1e6a 100644 --- a/lib/common/mobile/mobile-helper.ts +++ b/lib/common/mobile/mobile-helper.ts @@ -22,6 +22,7 @@ export class MobileHelper implements Mobile.IMobileHelper { this.$devicePlatformsConstants.Android, this.$devicePlatformsConstants.visionOS, this.$devicePlatformsConstants.Windows, + this.$devicePlatformsConstants.Linux, ]; } @@ -57,6 +58,14 @@ export class MobileHelper implements Mobile.IMobileHelper { ); } + public isLinuxPlatform(platform: string): boolean { + return !!( + platform && + this.$devicePlatformsConstants.Linux.toLowerCase() === + platform.toLowerCase() + ); + } + public isApplePlatform(platform: string): boolean { return this.isiOSPlatform(platform) || this.isvisionOSPlatform(platform); } @@ -70,6 +79,8 @@ export class MobileHelper implements Mobile.IMobileHelper { return "visionOS"; } else if (this.isWindowsPlatform(platform)) { return "Windows"; + } else if (this.isLinuxPlatform(platform)) { + return "Linux"; } return undefined; diff --git a/lib/common/test/unit-tests/mobile/devices-service.ts b/lib/common/test/unit-tests/mobile/devices-service.ts index ea39a4ae2c..4b2384fd1f 100644 --- a/lib/common/test/unit-tests/mobile/devices-service.ts +++ b/lib/common/test/unit-tests/mobile/devices-service.ts @@ -206,6 +206,7 @@ function createTestInjector(): IInjector { public async startLookingForDevices(): Promise {} } testInjector.register("windowsDeviceDiscovery", WindowsDeviceDiscoveryStub); + testInjector.register("linuxDeviceDiscovery", WindowsDeviceDiscoveryStub); testInjector.register("staticConfig", { CLIENT_NAME: "unit-tests" }); testInjector.register("devicePlatformsConstants", DevicePlatformsConstants); testInjector.register("androidEmulatorServices", AndroidEmulatorServices); diff --git a/lib/constants.ts b/lib/constants.ts index 3b5e37031b..6bf23ba5d9 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -31,10 +31,14 @@ export const SCOPED_ANDROID_RUNTIME_NAME = "@nativescript/android"; export const SCOPED_IOS_RUNTIME_NAME = "@nativescript/ios"; export const SCOPED_VISIONOS_RUNTIME_NAME = "@nativescript/visionos"; export const SCOPED_WINDOWS_RUNTIME_NAME = "@nativescript/windows"; -export const SCOPED_WINDOWS_HERMES_RUNTIME_NAME = "@nativescript/windows-hermes"; +export const SCOPED_WINDOWS_HERMES_RUNTIME_NAME = + "@nativescript/windows-hermes"; export const SCOPED_WINDOWS_V8_RUNTIME_NAME = "@nativescript/windows-v8"; -export const SCOPED_WINDOWS_QUICKJS_RUNTIME_NAME = "@nativescript/windows-quickjs"; +export const SCOPED_WINDOWS_QUICKJS_RUNTIME_NAME = + "@nativescript/windows-quickjs"; export const SCOPED_WINDOWS_JSC_RUNTIME_NAME = "@nativescript/windows-jsc"; +export const SCOPED_LINUX_RUNTIME_NAME = "@nativescript/linux"; +export const SCOPED_LINUX_QUICKJS_RUNTIME_NAME = "@nativescript/linux-quickjs"; export const PACKAGE_JSON_FILE_NAME = "package.json"; export const PACKAGE_LOCK_JSON_FILE_NAME = "package-lock.json"; export const ANDROID_DEVICE_APP_ROOT_TEMPLATE = `/data/data/%s/files`; @@ -374,13 +378,15 @@ export const enum PlatformTypes { android = "android", visionos = "visionos", windows = "windows", + linux = "linux", } export type SupportedPlatform = | PlatformTypes.ios | PlatformTypes.android | PlatformTypes.visionos - | PlatformTypes.windows; + | PlatformTypes.windows + | PlatformTypes.linux; export const PODFILE_NAME = "Podfile"; diff --git a/lib/controllers/debug-controller.ts b/lib/controllers/debug-controller.ts index ac941fb337..3babc86d74 100644 --- a/lib/controllers/debug-controller.ts +++ b/lib/controllers/debug-controller.ts @@ -43,7 +43,7 @@ export class DebugController extends EventEmitter implements IDebugController { private $liveSyncProcessDataService: ILiveSyncProcessDataService, private $logger: ILogger, private $mobileHelper: Mobile.IMobileHelper, - private $projectDataService: IProjectDataService + private $projectDataService: IProjectDataService, ) { super(); } @@ -52,18 +52,18 @@ export class DebugController extends EventEmitter implements IDebugController { public async startDebug(debugData: IDebugData): Promise { const { debugOptions: options } = debugData; const device = this.$devicesService.getDeviceByIdentifier( - debugData.deviceIdentifier + debugData.deviceIdentifier, ); if (!device) { this.$errors.fail( - `Cannot find device with identifier ${debugData.deviceIdentifier}.` + `Cannot find device with identifier ${debugData.deviceIdentifier}.`, ); } if (device.deviceInfo.status !== CONNECTED_STATUS) { this.$errors.fail( - `The device with identifier ${debugData.deviceIdentifier} is unreachable. Make sure it is Trusted and try again.` + `The device with identifier ${debugData.deviceIdentifier} is unreachable. Make sure it is Trusted and try again.`, ); } @@ -81,18 +81,18 @@ export class DebugController extends EventEmitter implements IDebugController { if ( !(await device.applicationManager.isApplicationInstalled( - debugData.applicationIdentifier + debugData.applicationIdentifier, )) ) { this.$errors.fail( - `The application ${debugData.applicationIdentifier} is not installed on device with identifier ${debugData.deviceIdentifier}.` + `The application ${debugData.applicationIdentifier} is not installed on device with identifier ${debugData.deviceIdentifier}.`, ); } const debugService = this.getDeviceDebugService(device); if (!debugService) { this.$errors.fail( - `Unsupported device OS: ${device.deviceInfo.platform}. You can debug your applications only on iOS, Android or Windows.` + `Unsupported device OS: ${device.deviceInfo.platform}. You can debug your applications only on iOS, Android, Windows or Linux.`, ); } @@ -101,12 +101,12 @@ export class DebugController extends EventEmitter implements IDebugController { return this.getDebugInformation( debugResultInfo, - device.deviceInfo.identifier + device.deviceInfo.identifier, ); } public enableDebugging( - enableDebuggingData: IEnableDebuggingData + enableDebuggingData: IEnableDebuggingData, ): Promise[] { const { deviceIdentifiers } = enableDebuggingData; @@ -114,27 +114,26 @@ export class DebugController extends EventEmitter implements IDebugController { this.enableDebuggingCore( enableDebuggingData.projectDir, deviceIdentifier, - enableDebuggingData.debugOptions - ) + enableDebuggingData.debugOptions, + ), ); } public async disableDebugging( - disableDebuggingData: IDisableDebuggingData + disableDebuggingData: IDisableDebuggingData, ): Promise { const { deviceIdentifiers, projectDir } = disableDebuggingData; for (const deviceIdentifier of deviceIdentifiers) { - const liveSyncProcessInfo = this.$liveSyncProcessDataService.getPersistedData( - projectDir - ); + const liveSyncProcessInfo = + this.$liveSyncProcessDataService.getPersistedData(projectDir); if (liveSyncProcessInfo.currentSyncAction) { await liveSyncProcessInfo.currentSyncAction; } const currentDeviceDescriptor = this.getDeviceDescriptor( projectDir, - deviceIdentifier + deviceIdentifier, ); if (currentDeviceDescriptor) { @@ -144,11 +143,11 @@ export class DebugController extends EventEmitter implements IDebugController { } const currentDevice = this.$devicesService.getDeviceByIdentifier( - currentDeviceDescriptor.identifier + currentDeviceDescriptor.identifier, ); if (!currentDevice) { this.$errors.fail( - `Couldn't disable debugging for ${deviceIdentifier}. Could not find device.` + `Couldn't disable debugging for ${deviceIdentifier}. Could not find device.`, ); } @@ -159,7 +158,7 @@ export class DebugController extends EventEmitter implements IDebugController { } public async attachDebugger( - attachDebuggerData: IAttachDebuggerData + attachDebuggerData: IAttachDebuggerData, ): Promise { // Default values if (attachDebuggerData.debugOptions) { @@ -179,12 +178,12 @@ export class DebugController extends EventEmitter implements IDebugController { } const projectData = this.$projectDataService.getProjectData( - attachDebuggerData.projectDir + attachDebuggerData.projectDir, ); const debugData = this.$debugDataService.getDebugData( attachDebuggerData.deviceIdentifier, projectData, - attachDebuggerData.debugOptions + attachDebuggerData.debugOptions, ); // const platformData = this.$platformsDataService.getPlatformData(settings.platform, projectData); @@ -193,7 +192,7 @@ export class DebugController extends EventEmitter implements IDebugController { const debugInfo = await this.startDebug(debugData); const result = this.printDebugInformation( debugInfo, - attachDebuggerData.debugOptions.forceDebuggerAttachedEvent + attachDebuggerData.debugOptions.forceDebuggerAttachedEvent, ); return result; } @@ -202,11 +201,11 @@ export class DebugController extends EventEmitter implements IDebugController { public async enableDebuggingCoreWithoutWaitingCurrentAction( projectDir: string, deviceIdentifier: string, - debugOptions: IDebugOptions + debugOptions: IDebugOptions, ): Promise { const deviceDescriptor = this.getDeviceDescriptor( projectDir, - deviceIdentifier + deviceIdentifier, ); if (!deviceDescriptor) { this.$errors.fail(`Couldn't enable debugging for ${deviceIdentifier}`); @@ -215,9 +214,8 @@ export class DebugController extends EventEmitter implements IDebugController { deviceDescriptor.debuggingEnabled = true; deviceDescriptor.debugOptions = debugOptions; - const currentDeviceInstance = this.$devicesService.getDeviceByIdentifier( - deviceIdentifier - ); + const currentDeviceInstance = + this.$devicesService.getDeviceByIdentifier(deviceIdentifier); const attachDebuggerData: IAttachDebuggerData = { deviceIdentifier, isEmulator: currentDeviceInstance.isEmulator, @@ -233,7 +231,7 @@ export class DebugController extends EventEmitter implements IDebugController { } catch (err) { this.$logger.trace( "Couldn't attach debugger, will modify options and try again.", - err + err, ); attachDebuggerData.debugOptions.start = false; try { @@ -241,7 +239,7 @@ export class DebugController extends EventEmitter implements IDebugController { } catch (innerErr) { this.$logger.trace( "Couldn't attach debugger with modified options.", - innerErr + innerErr, ); throw err; } @@ -252,7 +250,7 @@ export class DebugController extends EventEmitter implements IDebugController { public printDebugInformation( debugInformation: IDebugInformation, - fireDebuggerAttachedEvent: boolean = true + fireDebuggerAttachedEvent: boolean = true, ): IDebugInformation { if (!!debugInformation.url) { if (fireDebuggerAttachedEvent) { @@ -261,8 +259,8 @@ export class DebugController extends EventEmitter implements IDebugController { this.$logger.info( color.green( - `To start debugging, open the following URL in Chrome:${EOL}${debugInformation.url}${EOL}` - ) + `To start debugging, open the following URL in Chrome:${EOL}${debugInformation.url}${EOL}`, + ), ); } @@ -277,14 +275,13 @@ export class DebugController extends EventEmitter implements IDebugController { private getDeviceDescriptor( projectDir: string, - deviceIdentifier: string + deviceIdentifier: string, ): ILiveSyncDeviceDescriptor { - const deviceDescriptors = this.$liveSyncProcessDataService.getDeviceDescriptors( - projectDir - ); + const deviceDescriptors = + this.$liveSyncProcessDataService.getDeviceDescriptors(projectDir); const currentDeviceDescriptor = _.find( deviceDescriptors, - (d) => d.identifier === deviceIdentifier + (d) => d.identifier === deviceIdentifier, ); return currentDeviceDescriptor; @@ -294,25 +291,25 @@ export class DebugController extends EventEmitter implements IDebugController { if (!this._platformDebugServices[device.deviceInfo.identifier]) { const devicePlatform = device.deviceInfo.platform; if (this.$mobileHelper.isiOSPlatform(devicePlatform)) { - this._platformDebugServices[ - device.deviceInfo.identifier - ] = this.$injector.resolve("iOSDeviceDebugService", { device }); + this._platformDebugServices[device.deviceInfo.identifier] = + this.$injector.resolve("iOSDeviceDebugService", { device }); } else if (this.$mobileHelper.isAndroidPlatform(devicePlatform)) { - this._platformDebugServices[ - device.deviceInfo.identifier - ] = this.$injector.resolve("androidDeviceDebugService", { device }); + this._platformDebugServices[device.deviceInfo.identifier] = + this.$injector.resolve("androidDeviceDebugService", { device }); } else if (this.$mobileHelper.isWindowsPlatform(devicePlatform)) { - this._platformDebugServices[ - device.deviceInfo.identifier - ] = this.$injector.resolve("windowsDeviceDebugService", { device }); + this._platformDebugServices[device.deviceInfo.identifier] = + this.$injector.resolve("windowsDeviceDebugService", { device }); + } else if (this.$mobileHelper.isLinuxPlatform(devicePlatform)) { + this._platformDebugServices[device.deviceInfo.identifier] = + this.$injector.resolve("linuxDeviceDebugService", { device }); } else { this.$errors.fail( - DebugCommandErrors.UNSUPPORTED_DEVICE_OS_FOR_DEBUGGING + DebugCommandErrors.UNSUPPORTED_DEVICE_OS_FOR_DEBUGGING, ); } this.attachConnectionErrorHandlers( - this._platformDebugServices[device.deviceInfo.identifier] + this._platformDebugServices[device.deviceInfo.identifier], ); } @@ -320,20 +317,20 @@ export class DebugController extends EventEmitter implements IDebugController { } private attachConnectionErrorHandlers( - platformDebugService: IDeviceDebugService + platformDebugService: IDeviceDebugService, ) { let connectionErrorHandler = (e: Error) => this.emit(CONNECTION_ERROR_EVENT_NAME, e); connectionErrorHandler = connectionErrorHandler.bind(this); platformDebugService.on( CONNECTION_ERROR_EVENT_NAME, - connectionErrorHandler + connectionErrorHandler, ); } private getDebugInformation( debugResultInfo: IDebugResultInfo, - deviceIdentifier: string + deviceIdentifier: string, ): IDebugInformation { const debugInfo: IDebugInformation = { url: debugResultInfo.debugUrl, @@ -356,11 +353,10 @@ export class DebugController extends EventEmitter implements IDebugController { private async enableDebuggingCore( projectDir: string, deviceIdentifier: string, - debugOptions: IDebugOptions + debugOptions: IDebugOptions, ): Promise { - const liveSyncProcessInfo = this.$liveSyncProcessDataService.getPersistedData( - projectDir - ); + const liveSyncProcessInfo = + this.$liveSyncProcessDataService.getPersistedData(projectDir); if (liveSyncProcessInfo && liveSyncProcessInfo.currentSyncAction) { await liveSyncProcessInfo.currentSyncAction; } @@ -368,7 +364,7 @@ export class DebugController extends EventEmitter implements IDebugController { return this.enableDebuggingCoreWithoutWaitingCurrentAction( projectDir, deviceIdentifier, - debugOptions + debugOptions, ); } } diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index cbf234616a..5bf88334ef 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -548,7 +548,10 @@ export class PrepareController "package.json", ); } else if ( - this.$mobileHelper.isWindowsPlatform(platformData.platformNameLowerCase) + this.$mobileHelper.isWindowsPlatform( + platformData.platformNameLowerCase, + ) || + this.$mobileHelper.isLinuxPlatform(platformData.platformNameLowerCase) ) { packagePath = path.join( platformData.appDestinationDirectoryPath, diff --git a/lib/data/build-data.ts b/lib/data/build-data.ts index e5e7c78086..cfa01cc241 100644 --- a/lib/data/build-data.ts +++ b/lib/data/build-data.ts @@ -45,6 +45,15 @@ export class IOSBuildData extends BuildData implements IiOSBuildData { } } +export class LinuxBuildData extends BuildData { + public arch?: string; + + constructor(projectDir: string, platform: string, data: any) { + super(projectDir, platform, data); + this.arch = data.arch; + } +} + export class WindowsBuildData extends BuildData { public certificate: string; public certificatePassword: string; @@ -65,7 +74,7 @@ export class WindowsBuildData extends BuildData { this.msixBundle = data.msixBundle || data.msixbundle; this.architectures = data.arch ? [data.arch] - : data.architectures ?? ["x64"]; + : (data.architectures ?? ["x64"]); this.sourceProtect = data.sourceProtect; this.sourceProtectKeyHex = data.sourceProtectKeyHex; } diff --git a/lib/data/prepare-data.ts b/lib/data/prepare-data.ts index c083eee17f..3685ac3162 100644 --- a/lib/data/prepare-data.ts +++ b/lib/data/prepare-data.ts @@ -70,6 +70,8 @@ export class IOSPrepareData extends PrepareData { export class AndroidPrepareData extends PrepareData {} +export class LinuxPrepareData extends PrepareData {} + export class WindowsPrepareData extends PrepareData { public packageFamilyName?: string; diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index d013d42b07..e7819dfb82 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -144,6 +144,8 @@ interface INsConfigIOS extends INsConfigPlaform { interface INSConfigVisionOS extends INsConfigIOS {} +interface INsConfigLinux extends INsConfigPlaform {} + interface INsConfigWindows extends INsConfigPlaform { /** * Seal the app's webpack output into an encrypted app.nsbundle instead of shipping it as @@ -218,6 +220,7 @@ interface INsConfig { android?: INsConfigAndroid; visionos?: INSConfigVisionOS; windows?: INsConfigWindows; + linux?: INsConfigLinux; ignoredNativeDependencies?: string[]; hooks?: INsConfigHooks[]; projectName?: string; diff --git a/lib/device-path-provider.ts b/lib/device-path-provider.ts index 90c4227087..6491bcd6c0 100644 --- a/lib/device-path-provider.ts +++ b/lib/device-path-provider.ts @@ -50,7 +50,8 @@ export class DevicePathProvider implements IDevicePathProvider { projectRoot = path.join(projectRoot, syncFolderName); } } else if ( - this.$mobileHelper.isWindowsPlatform(device.deviceInfo.platform) + this.$mobileHelper.isWindowsPlatform(device.deviceInfo.platform) || + this.$mobileHelper.isLinuxPlatform(device.deviceInfo.platform) ) { const projectData = options.projectData; if (projectData) { diff --git a/lib/project-data.ts b/lib/project-data.ts index 7e1fafd1b3..36c37b0fa8 100644 --- a/lib/project-data.ts +++ b/lib/project-data.ts @@ -343,6 +343,7 @@ export class ProjectData implements IProjectData { android: "", visionos: "", windows: "", + linux: "", }; } @@ -351,6 +352,7 @@ export class ProjectData implements IProjectData { android: config.id, visionos: config.id, windows: config.id, + linux: config.id, }; if (config.ios && config.ios.id) { @@ -365,6 +367,9 @@ export class ProjectData implements IProjectData { if (config.windows && config.windows.id) { identifier.windows = config.windows.id; } + if (config.linux && config.linux.id) { + identifier.linux = config.linux.id; + } return identifier; } diff --git a/lib/resolvers/livesync-service-resolver.ts b/lib/resolvers/livesync-service-resolver.ts index bf5e8d74fc..10438b2765 100644 --- a/lib/resolvers/livesync-service-resolver.ts +++ b/lib/resolvers/livesync-service-resolver.ts @@ -16,6 +16,8 @@ export class LiveSyncServiceResolver implements ILiveSyncServiceResolver { return this.$injector.resolve("androidLiveSyncService"); } else if (this.$mobileHelper.isWindowsPlatform(platform)) { return this.$injector.resolve("windowsLiveSyncService"); + } else if (this.$mobileHelper.isLinuxPlatform(platform)) { + return this.$injector.resolve("linuxLiveSyncService"); } this.$errors.fail( diff --git a/lib/services/build-data-service.ts b/lib/services/build-data-service.ts index 305fd4bb79..efec6a6083 100644 --- a/lib/services/build-data-service.ts +++ b/lib/services/build-data-service.ts @@ -2,6 +2,7 @@ import { AndroidBuildData, IOSBuildData, WindowsBuildData, + LinuxBuildData, } from "../data/build-data"; import { IBuildDataService } from "../definitions/build"; import { injector } from "../common/yok"; @@ -16,6 +17,8 @@ export class BuildDataService implements IBuildDataService { return new AndroidBuildData(projectDir, platform, data); } else if (this.$mobileHelper.isWindowsPlatform(platform)) { return new WindowsBuildData(projectDir, platform, data); + } else if (this.$mobileHelper.isLinuxPlatform(platform)) { + return new LinuxBuildData(projectDir, platform, data); } } } diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 34062684d6..461ae1bf70 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -598,6 +598,7 @@ export class BundlerCompilerService this.$options.hostProjectModuleName, USER_PROJECT_PLATFORMS_IOS: this.$options.hostProjectPath, USER_PROJECT_PLATFORMS_WINDOWS: this.$options.hostProjectPath, + USER_PROJECT_PLATFORMS_LINUX: this.$options.hostProjectPath, }); } diff --git a/lib/services/linux-device-debug-service.ts b/lib/services/linux-device-debug-service.ts new file mode 100644 index 0000000000..05c3bc3cff --- /dev/null +++ b/lib/services/linux-device-debug-service.ts @@ -0,0 +1,90 @@ +import * as net from "net"; +import { sleep } from "../common/helpers"; +import { performanceLog } from "../common/decorators"; +import { DebugServiceBase } from "./debug-service-base"; +import { APPLICATION_RESPONSE_TIMEOUT_SECONDS } from "../constants"; +import { + IDeviceDebugService, + IDebugData, + IDebugOptions, + IDebugResultInfo, +} from "../definitions/debug"; +import { IErrors } from "../common/declarations"; +import { injector } from "../common/yok"; +import { LinuxApplicationManager } from "../common/mobile/linux/linux-application-manager"; + +/** The app is started with `--inspect=`; wait for the inspector to listen. */ +export class LinuxDeviceDebugService + extends DebugServiceBase + implements IDeviceDebugService +{ + public get platform() { + return "linux"; + } + + constructor( + protected device: Mobile.IDevice, + protected $devicesService: Mobile.IDevicesService, + private $errors: IErrors, + private $logger: ILogger, + ) { + super(device, $devicesService); + } + + @performanceLog() + public async debug( + debugData: IDebugData, + debugOptions: IDebugOptions, + ): Promise { + const result: IDebugResultInfo = { debugUrl: null }; + if (debugOptions.stop) { + return result; + } + const port = (this.device.applicationManager as LinuxApplicationManager) + .inspectorPort; + if (!(await this.waitForPort(port, debugOptions))) { + this.$errors.fail( + `The application ${debugData.applicationIdentifier} is not running with the debugger enabled. Start it with \`ns debug linux\`.`, + ); + } + this.$logger.info("# NativeScript Debugger started #"); + result.debugUrl = this.getChromeDebugUrl(debugOptions, port); + return result; + } + + public async debugStop(): Promise { + // the inspector lives in the app process + } + + private async waitForPort( + port: number, + debugOptions: IDebugOptions, + ): Promise { + const timeoutSeconds = + parseInt(debugOptions.timeout, 10) || + APPLICATION_RESPONSE_TIMEOUT_SECONDS; + const deadline = Date.now() + timeoutSeconds * 1000; + while (!(await this.isListening(port))) { + if (Date.now() >= deadline) { + return false; + } + await sleep(250); + } + return true; + } + + private isListening(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host: "127.0.0.1", port }); + const done = (listening: boolean) => { + socket.destroy(); + resolve(listening); + }; + socket.setTimeout(1000, () => done(false)); + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + }); + } +} + +injector.register("linuxDeviceDebugService", LinuxDeviceDebugService, false); diff --git a/lib/services/linux-native-code.ts b/lib/services/linux-native-code.ts new file mode 100644 index 0000000000..ea75fd6306 --- /dev/null +++ b/lib/services/linux-native-code.ts @@ -0,0 +1,433 @@ +import * as path from "path"; +import * as fs from "fs"; +import * as crypto from "crypto"; +import { IChildProcess, ISpawnResult } from "../common/declarations"; + +/** + * Native code of the app (`App_Resources/Linux`) or a plugin (`platforms/linux`): + * + * src/ C / C++ sources (and headers), compiled into lib.so + * include/ public headers; when present only these are exposed to JS + * lib// prebuilt .so (bundled in lib/) and .a (linked into lib.so) + * native.json { "pkg-config": [], "cflags": [], "libs": [] } + * + * Headers are run through the metadata generator into `metadata/.bin`, + * which the runtime loads next to the SDK metadata. + * + * Metadata filters (`native-api-usage.json`, written by prepare as + * whitelist.mdg / blacklist.mdg) shrink the SDK metadata; units are always + * kept but honor the blacklist. + */ +export interface LinuxNativeUnit { + /** module / library name, a C identifier */ + name: string; + dir: string; +} + +export interface LinuxNativeBuildOptions { + arch: string; + release: boolean; + /** output folder: gets lib/ and metadata/ */ + stage: string; + /** per unit build cache */ + workDir: string; + /** folder with metadata-generator and lib/clang */ + generatorDir: string; + /** generated .d.ts go here */ + typingsDir: string; + /** `module:symbol` patterns excluded from the metadata */ + blacklist?: string; +} + +interface NativeConfig { + "pkg-config"?: string[]; + cflags?: string[]; + libs?: string[]; +} + +const SOURCE = /\.(c|cc|cpp|cxx)$/; +const CXX_SOURCE = /\.(cc|cpp|cxx)$/; +const HEADER = /\.(h|hh|hpp)$/; +/** App_Resources/Linux entries that are build inputs, not resources */ +export const NATIVE_ENTRIES = ["src", "include", "lib", "native.json"]; + +/** `@scope/my-plugin` -> `scope_my_plugin` */ +export function nativeUnitName(name: string): string { + const id = name.replace(/^@/, "").replace(/[^A-Za-z0-9_]/g, "_"); + return /^[0-9]/.test(id) ? `_${id}` : id; +} + +export function hasNativeCode(dir: string): boolean { + return NATIVE_ENTRIES.some((e) => fs.existsSync(path.join(dir, e))); +} + +function readIfExists(file?: string): string { + return file && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; +} + +function walk(dir: string, filter: RegExp): string[] { + if (!fs.existsSync(dir)) { + return []; + } + return (fs.readdirSync(dir, { recursive: true }) as string[]) + .filter((f) => filter.test(f)) + .map((f) => path.join(dir, f)) + .sort(); +} + +/** + * `index.d.ts` for an app's `typings/linux`: the SDK typings in `sdk/` and + * the native code typings (`c!.d.ts`) next to it. + */ +export function writeTypingsIndex(dir: string): void { + const references = [ + ...(fs.existsSync(path.join(dir, "sdk", "index.d.ts")) + ? ["./sdk/index.d.ts"] + : []), + ...fs + .readdirSync(dir) + .filter((f) => f.startsWith("c!") && f.endsWith(".d.ts")) + .sort() + .map((f) => `./${f}`), + ]; + fs.writeFileSync( + path.join(dir, "index.d.ts"), + references.map((r) => `/// \n`).join(""), + ); +} + +export class LinuxNativeCodeBuilder { + constructor( + private $childProcess: IChildProcess, + private $logger: ILogger, + ) {} + + public async build( + units: LinuxNativeUnit[], + options: LinuxNativeBuildOptions, + ): Promise { + for (const unit of units) { + await this.buildUnit(unit, options); + } + } + + private async buildUnit( + unit: LinuxNativeUnit, + options: LinuxNativeBuildOptions, + ): Promise { + const prebuiltDir = path.join(unit.dir, "lib", options.arch); + const prebuilt = fs.existsSync(prebuiltDir) + ? fs.readdirSync(prebuiltDir).sort() + : []; + const shared = prebuilt.filter((f) => /\.so(\.\d+)*$/.test(f)); + const statics = prebuilt + .filter((f) => f.endsWith(".a")) + .map((f) => path.join(prebuiltDir, f)); + const sources = walk(path.join(unit.dir, "src"), SOURCE); + const publicDir = ["include", "src"] + .map((d) => path.join(unit.dir, d)) + .find((d) => fs.existsSync(d)); + const headers = publicDir ? walk(publicDir, HEADER) : []; + if ( + !sources.length && + !statics.length && + !shared.length && + !headers.length + ) { + return; + } + + const stageLib = path.join(options.stage, "lib"); + fs.mkdirSync(stageLib, { recursive: true }); + for (const file of shared) { + fs.cpSync(path.join(prebuiltDir, file), path.join(stageLib, file), { + verbatimSymlinks: true, + }); + } + + const work = path.join(options.workDir, unit.name); + const library = `lib${unit.name}.so`; + const metadata = `${unit.name}.bin`; + const compiles = sources.length > 0 || statics.length > 0; + const stamp = path.join(work, "stamp"); + const hash = this.inputsHash(unit.dir, options); + if (!fs.existsSync(stamp) || fs.readFileSync(stamp, "utf8") !== hash) { + fs.rmSync(work, { recursive: true, force: true }); + fs.mkdirSync(work, { recursive: true }); + const config = this.config(unit.dir); + const pkg = config["pkg-config"] ?? []; + const pkgCflags = pkg.length + ? await this.pkgConfig(["--cflags", ...pkg]) + : []; + const includes = ["src", "include"] + .map((d) => path.join(unit.dir, d)) + .filter((d) => fs.existsSync(d)) + .map((d) => `-I${d}`); + if (compiles) { + await this.compile( + unit, + sources, + statics, + shared, + prebuiltDir, + [...includes, ...pkgCflags, ...(config.cflags ?? [])], + pkg, + config.libs ?? [], + path.join(work, library), + options.release, + ); + } + if (headers.length) { + const links = compiles + ? [library] + : shared.filter( + (f) => !fs.lstatSync(path.join(prebuiltDir, f)).isSymbolicLink(), + ); + await this.generate( + unit, + publicDir, + headers, + links, + [...includes, ...pkgCflags, ...(config.cflags ?? [])], + work, + options, + ); + } + fs.writeFileSync(stamp, hash); + } + + if (compiles) { + fs.copyFileSync(path.join(work, library), path.join(stageLib, library)); + } + if (fs.existsSync(path.join(work, metadata))) { + const stageMetadata = path.join(options.stage, "metadata"); + fs.mkdirSync(stageMetadata, { recursive: true }); + fs.copyFileSync( + path.join(work, metadata), + path.join(stageMetadata, metadata), + ); + } + const typings = path.join(work, "typings"); + if (fs.existsSync(typings)) { + fs.mkdirSync(options.typingsDir, { recursive: true }); + fs.cpSync(typings, options.typingsDir, { recursive: true }); + } + } + + private config(dir: string): NativeConfig { + const file = path.join(dir, "native.json"); + if (!fs.existsSync(file)) { + return {}; + } + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (e) { + throw new Error(`${file}: ${e.message}`); + } + } + + /** Changes to any input (or the generator / build type) rebuild the unit. */ + private inputsHash(dir: string, options: LinuxNativeBuildOptions): string { + const hash = crypto.createHash("sha1"); + hash.update(`${options.arch}|${options.release}\n`); + hash.update(readIfExists(options.blacklist)); + const generator = path.join(options.generatorDir, "metadata-generator"); + for (const file of [ + generator, + ...NATIVE_ENTRIES.flatMap((e) => this.files(path.join(dir, e))), + ]) { + const stat = fs.existsSync(file) && fs.statSync(file); + hash.update(`${file}|${stat ? `${stat.size}|${stat.mtimeMs}` : "-"}\n`); + } + return hash.digest("hex"); + } + + private files(entry: string): string[] { + if (!fs.existsSync(entry)) { + return []; + } + if (!fs.statSync(entry).isDirectory()) { + return [entry]; + } + return (fs.readdirSync(entry, { recursive: true }) as string[]) + .map((f) => path.join(entry, f)) + .sort(); + } + + private async compile( + unit: LinuxNativeUnit, + sources: string[], + statics: string[], + shared: string[], + prebuiltDir: string, + cflags: string[], + pkg: string[], + libs: string[], + output: string, + release: boolean, + ): Promise { + const cxx = sources.some((s) => CXX_SOURCE.test(s)); + const driver = cxx ? process.env.CXX || "c++" : process.env.CC || "cc"; + const args = [ + "-shared", + "-fPIC", + ...(release ? ["-O2"] : ["-O0", "-g"]), + "-o", + output, + `-Wl,-soname,${path.basename(output)}`, + // bundled libraries sit next to this one + "-Wl,-rpath,$ORIGIN", + ...cflags, + ...sources, + ...(statics.length + ? ["-Wl,--whole-archive", ...statics, "-Wl,--no-whole-archive"] + : []), + ...(shared.length + ? [`-L${prebuiltDir}`, ...shared.map((f) => `-l:${f}`)] + : []), + ...(pkg.length ? await this.pkgConfig(["--libs", ...pkg]) : []), + ...libs, + ]; + this.$logger.info(`Compiling native code: ${unit.name}`); + await this.run(driver, args, unit.dir); + } + + private async generate( + unit: LinuxNativeUnit, + publicDir: string, + headers: string[], + links: string[], + cflags: string[], + work: string, + options: LinuxNativeBuildOptions, + ): Promise { + const generator = path.join(options.generatorDir, "metadata-generator"); + if (!fs.existsSync(generator)) { + this.$logger.warn( + `The metadata generator is not part of this runtime package, ${unit.name}'s C APIs won't be available from JavaScript.`, + ); + return; + } + const gen = path.join(work, "gen"); + fs.mkdirSync(gen, { recursive: true }); + const modulemap = path.join(gen, "module.modulemap"); + fs.writeFileSync( + modulemap, + [ + `module ${unit.name} {`, + ` umbrella "${publicDir}"`, + ...links.map((l) => ` link "${l}"`), + "}", + "", + ].join("\n"), + ); + const umbrella = path.join(gen, "umbrella.h"); + fs.writeFileSync( + umbrella, + headers.map((h) => `#include "${h}"\n`).join(""), + ); + // only this unit's declarations, not the system headers it includes + const whitelist = path.join(gen, "whitelist.txt"); + fs.writeFileSync(whitelist, `${unit.name}\n`); + const blacklist = + options.blacklist && fs.existsSync(options.blacklist) + ? ["-blacklist-modules-file", options.blacklist] + : []; + // clang finds its builtin headers relative to the working directory (lib/clang) + await this.run( + generator, + [ + "-input-umbrella", + umbrella, + "-whitelist-modules-file", + whitelist, + ...blacklist, + "-output-bin", + path.join(work, `${unit.name}.bin`), + "-output-typescript", + path.join(work, "typings"), + "Xclang", + `-fmodule-map-file=${modulemap}`, + ...cflags, + ], + options.generatorDir, + ); + } + + /** + * Copies the SDK metadata to `output`, filtered when there are whitelist / + * blacklist files. Reuses the previous result while the inputs match. + */ + public async filterMetadata( + input: string, + output: string, + options: { + toolsDir: string; + cacheDir: string; + whitelist: string; + blacklist: string; + }, + ): Promise { + const { toolsDir, cacheDir, whitelist, blacklist } = options; + const filters = [whitelist, blacklist].filter((f) => fs.existsSync(f)); + const tool = path.join(toolsDir, "ns-metadata-filter"); + if (!filters.length || !fs.existsSync(tool)) { + if (filters.length) { + this.$logger.warn( + "This runtime package has no metadata filter, native-api-usage.json is ignored.", + ); + } + fs.copyFileSync(input, output); + return; + } + const stat = fs.statSync(input); + const hash = crypto + .createHash("sha1") + .update(`${input}|${stat.size}|${stat.mtimeMs}\n`) + .update(readIfExists(whitelist)) + .update("\0") + .update(readIfExists(blacklist)) + .digest("hex"); + const cached = path.join(cacheDir, path.basename(output)); + const stamp = `${cached}.stamp`; + if ( + !fs.existsSync(cached) || + !fs.existsSync(stamp) || + fs.readFileSync(stamp, "utf8") !== hash + ) { + fs.mkdirSync(cacheDir, { recursive: true }); + const args = [input, cached]; + if (fs.existsSync(whitelist)) args.push("--whitelist", whitelist); + if (fs.existsSync(blacklist)) args.push("--blacklist", blacklist); + const result = await this.run(tool, args, toolsDir); + this.$logger.info(`Metadata filtered: ${result.stdout.trim()}`); + fs.writeFileSync(stamp, hash); + } + fs.copyFileSync(cached, output); + } + + private async pkgConfig(args: string[]): Promise { + const result = await this.run("pkg-config", args, process.cwd()); + return result.stdout.split(/\s+/).filter(Boolean); + } + + private async run( + command: string, + args: string[], + cwd: string, + ): Promise { + const result = await this.$childProcess.spawnFromEvent( + command, + args, + "close", + { cwd }, + { throwError: false }, + ); + if (result.exitCode !== 0) { + throw new Error( + `${command} failed (exit code ${result.exitCode}):\n${result.stderr || result.stdout}`, + ); + } + return result; + } +} diff --git a/lib/services/linux-project-service.ts b/lib/services/linux-project-service.ts new file mode 100644 index 0000000000..b167eacc20 --- /dev/null +++ b/lib/services/linux-project-service.ts @@ -0,0 +1,597 @@ +import * as path from "path"; +import * as fs from "fs"; +import * as constants from "../constants"; +import * as projectServiceBaseLib from "./platform-project-service-base"; +import { + IPlatformData, + IValidBuildOutputData, + IPlatformEnvironmentRequirements, +} from "../definitions/platform"; +import { + IProjectData, + IProjectDataService, + IValidatePlatformOutput, +} from "../definitions/project"; +import { IOptions, IDependencyData } from "../declarations"; +import { IPluginData, IPluginsService } from "../definitions/plugins"; +import { + IFileSystem, + IChildProcess, + IRelease, + ISpawnResult, +} from "../common/declarations"; +import { injector } from "../common/yok"; +import { INotConfiguredEnvOptions } from "../common/definitions/commands"; +import { IProjectChangesInfo } from "../definitions/project-changes"; +import { + LinuxNativeCodeBuilder, + LinuxNativeUnit, + NATIVE_ENTRIES, + hasNativeCode, + nativeUnitName, + writeTypingsIndex, +} from "./linux-native-code"; + +/** App_Resources/Linux files consumed by the build (desktop entry, icon, AppStream, metadata filters). */ +const BUILD_INPUTS = [ + "native-api-usage.json", + "app.desktop", + "app.metainfo.xml", + "icon.png", + "icon.svg", +]; + +/** Rust's `std::env::consts::ARCH`, used in the metadata file name the runtime looks for. */ +const RUNTIME_ARCH: Record = { + x64: "x86_64", + arm64: "aarch64", +}; + +/** + * Linux apps run on a prebuilt runtime (`@nativescript/linux`), so a build is + * a staged folder: the runtime executable named after the project, its + * metadata and the bundled `app/`. Release builds are also packed as .tar.gz. + * + * Runtime package layout (`/framework`): + * __PROJECT_NAME__/ platform project template (bundles go to app/) + * runtime//ns-linux the runtime executable + * runtime//metadata-.bin + * typings/ generated .d.ts for the C APIs in the metadata + * tools// metadata generator for app / plugin native code + * + * Native code (see linux-native-code.ts) is built into `lib/` and `metadata/` + * next to the executable. + */ +export class LinuxProjectService + extends projectServiceBaseLib.PlatformProjectServiceBase +{ + private static PLATFORM_NAME = "linux"; + private _platformData: IPlatformData | null = null; + + constructor( + $fs: IFileSystem, + $projectDataService: IProjectDataService, + private $options: IOptions, + private $logger: ILogger, + private $childProcess: IChildProcess, + private $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, + ) { + super($fs, $projectDataService); + } + + public getPlatformData(projectData: IProjectData): IPlatformData { + if (!projectData && !this._platformData) { + throw new Error( + "First call of getPlatformData without providing projectData.", + ); + } + if (projectData && projectData.platformsDir) { + const projectRoot = this.$options.hostProjectPath + ? this.$options.hostProjectPath + : path.join( + projectData.platformsDir, + LinuxProjectService.PLATFORM_NAME, + ); + const runtimePackage = this.$projectDataService.getRuntimePackage( + projectData.projectDir, + constants.PlatformTypes.linux, + ); + const appProject = path.join(projectRoot, projectData.projectName); + this._platformData = { + frameworkPackageName: runtimePackage.name, + normalizedPlatformName: "Linux", + platformNameLowerCase: "linux", + appDestinationDirectoryPath: appProject, + platformProjectService: this, + projectRoot, + getBuildOutputPath: (options?: any): string => + path.join(appProject, options?.release ? "dist" : "bin"), + getValidBuildOutputData: (options?: any): IValidBuildOutputData => + options?.release + ? { + packageNames: [`${projectData.projectName}.tar.gz`], + regexes: [/\.tar\.gz$/], + } + : { packageNames: [projectData.projectName] }, + configurationFileName: "", + frameworkDirectoriesExtensions: [], + frameworkDirectoriesNames: ["runtime", "typings"], + targetedOS: ["linux"], + relativeToFrameworkConfigurationFilePath: "", + fastLivesyncFileExtensions: [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".bmp", + ".svg", + ], + }; + } + return this._platformData as IPlatformData; + } + + public async validateOptions(): Promise { + return true; + } + + public async validate( + projectData: IProjectData, + options: IOptions, + notConfiguredEnvOptions?: INotConfiguredEnvOptions, + ): Promise { + const checkEnvironmentRequirementsOutput = + await this.$platformEnvironmentRequirements.checkEnvironmentRequirements({ + platform: this.getPlatformData(projectData).normalizedPlatformName, + projectDir: projectData.projectDir, + options, + notConfiguredEnvOptions, + }); + return { checkEnvironmentRequirementsOutput }; + } + + public getAppResourcesDestinationDirectoryPath( + projectData: IProjectData, + ): string { + return path.join(this.appProject(projectData), "App_Resources"); + } + + public async createProject( + frameworkDir: string, + _frameworkVersion: string, + projectData: IProjectData, + ): Promise { + const projectRoot = this.getPlatformData(projectData).projectRoot; + this.$fs.ensureDirectoryExists(projectRoot); + for (const entry of fs.readdirSync(frameworkDir)) { + // build tools are used from the package, see toolsDir() + if (entry === "tools") { + continue; + } + const target = + entry === "__PROJECT_NAME__" ? projectData.projectName : entry; + fs.cpSync( + path.join(frameworkDir, entry), + path.join(projectRoot, target), + { recursive: true }, + ); + } + this.$fs.ensureDirectoryExists( + path.join(this.appProject(projectData), constants.APP_FOLDER_NAME), + ); + } + + public async interpolateData(_projectData: IProjectData): Promise { + // nothing to interpolate: there is no native project + } + + public interpolateConfigurationFile(_projectData: IProjectData): void { + // no configuration file + } + + public afterCreateProject( + _projectRoot: string, + _projectData: IProjectData, + ): void { + // nothing to do + } + + /** Stages the runtime, metadata and app into the output folder (and packs it for release). */ + public async buildProject( + projectRoot: string, + projectData: IProjectData, + buildConfig: any, + ): Promise { + const release = !!buildConfig?.release; + const arch = this.arch(); + const runtimeDir = path.join(projectRoot, "runtime", arch); + const runtime = path.join(runtimeDir, "ns-linux"); + if (!this.$fs.exists(runtime)) { + throw new Error( + `The Linux runtime for ${arch} was not found at ${runtime}. Reinstall the platform with \`ns platform clean linux\`.`, + ); + } + + const appProject = this.appProject(projectData); + const name = projectData.projectName; + const outputRoot = this.getPlatformData(projectData).getBuildOutputPath({ + release, + }); + const stage = release ? path.join(outputRoot, name) : outputRoot; + this.$fs.deleteDirectory(stage); + this.$fs.ensureDirectoryExists(stage); + + const executable = path.join(stage, name); + fs.copyFileSync(runtime, executable); + fs.chmodSync(executable, 0o755); + const native = new LinuxNativeCodeBuilder(this.$childProcess, this.$logger); + for (const file of fs.readdirSync(runtimeDir)) { + if (file.startsWith("metadata-") && file.endsWith(".bin")) { + await native.filterMetadata( + path.join(runtimeDir, file), + path.join(stage, file), + { + toolsDir: this.toolsDir(projectData, arch), + cacheDir: path.join(projectRoot, "build", "metadata"), + whitelist: path.join( + projectRoot, + constants.MetadataFilteringConstants.WHITELIST_FILE_NAME, + ), + blacklist: path.join( + projectRoot, + constants.MetadataFilteringConstants.BLACKLIST_FILE_NAME, + ), + }, + ); + } + } + fs.cpSync( + path.join(appProject, constants.APP_FOLDER_NAME), + path.join(stage, constants.APP_FOLDER_NAME), + { + recursive: true, + }, + ); + const resources = this.getAppResourcesDestinationDirectoryPath(projectData); + if (this.$fs.exists(resources)) { + // staged as lib/, metadata/, icons/ and the desktop entry instead + const native = new Set( + [...NATIVE_ENTRIES, ...BUILD_INPUTS].map((e) => + path.join(resources, "Linux", e), + ), + ); + fs.cpSync(resources, path.join(stage, "App_Resources"), { + recursive: true, + filter: (src) => !native.has(src), + }); + } + + await this.buildNativeCode(projectData, stage, release); + + this.stageDesktopEntry(stage, projectData, release); + + if (release) { + const archive = path.join(outputRoot, `${name}.tar.gz`); + await this.$childProcess.spawnFromEvent( + "tar", + ["-czf", archive, "-C", outputRoot, name], + "close", + {}, + { + throwError: true, + }, + ); + this.$logger.info(`Linux package: ${archive}`); + } + } + + public async prepareProject( + projectData: IProjectData, + _prepareData: T, + ): Promise { + // plugin native code is built from node_modules, drop copies older CLIs staged + this.$fs.deleteDirectory( + path.join(this.appProject(projectData), "plugins"), + ); + } + + public async checkForChanges( + _changesInfo: IProjectChangesInfo, + _prepareData: any, + _projectData: IProjectData, + ): Promise { + // no native project to check + } + + /** + * Mirrors `App_Resources/Linux` into the platform project. A project without + * one gets the runtime package's defaults (icon, .desktop and metainfo templates). + */ + public prepareAppResources(projectData: IProjectData): void { + const resourcesDir = projectData.appResourcesDirectoryPath; + let source = ["Linux", "linux"] + .map((d) => resourcesDir && path.join(resourcesDir, d)) + .find((d) => d && this.$fs.exists(d)); + const defaults = path.join( + this.getPlatformData(projectData).projectRoot, + "App_Resources", + "Linux", + ); + if (!source && resourcesDir && this.$fs.exists(defaults)) { + source = path.join(resourcesDir, "Linux"); + fs.cpSync(defaults, source, { recursive: true }); + this.$logger.info(`Added the default Linux resources to ${source}`); + } + const destination = path.join( + this.getAppResourcesDestinationDirectoryPath(projectData), + "Linux", + ); + this.$fs.deleteDirectory(destination); + if (source) { + fs.cpSync(source, destination, { recursive: true }); + } + } + + public isPlatformPrepared( + projectRoot: string, + projectData: IProjectData, + ): boolean { + return this.$fs.exists(path.join(projectRoot, projectData.projectName)); + } + + /** Plugin native code is compiled at build time, see nativeUnits(). */ + public async preparePluginNativeCode( + _pluginData: IPluginData, + _projectData: IProjectData, + ): Promise {} + + public async removePluginNativeCode( + _pluginData: IPluginData, + _projectData: IProjectData, + ): Promise {} + + public async beforePrepareAllPlugins( + _projectData: IProjectData, + dependencies?: IDependencyData[], + ): Promise { + return dependencies ?? []; + } + + public async handleNativeDependenciesChange( + _projectData: IProjectData, + _opts: IRelease, + ): Promise { + // nothing to rebuild + } + + public async cleanDeviceTempFolder( + _deviceIdentifier: string, + _projectData: IProjectData, + ): Promise { + // apps run in place + } + + public async processConfigurationFilesFromAppResources( + _projectData: IProjectData, + _opts: { release: boolean }, + ): Promise { + // no configuration files + } + + public ensureConfigurationFileInAppResources( + _projectData: IProjectData, + ): void { + // no configuration file + } + + public async stopServices(_projectRoot: string): Promise { + return { stderr: "", stdout: "", exitCode: 0 }; + } + + public async cleanProject(projectRoot: string): Promise { + this.$fs.deleteDirectory(path.join(projectRoot, constants.BUILD_DIR)); + } + + /** + * Desktop integration next to the executable: icons/hicolor//apps/.png|svg, + * .desktop and .metainfo.xml from App_Resources/Linux + * (templates use __APP_ID__, __APP_NAME__, __APP_VERSION__, __APP_DATE__ and __EXEC__). Release builds + * also get install.sh, which installs the app into ~/.local. + */ + private stageDesktopEntry( + stage: string, + projectData: IProjectData, + release: boolean, + ): void { + const resources = path.join( + this.getAppResourcesDestinationDirectoryPath(projectData), + "Linux", + ); + const appId = + projectData.projectIdentifiers?.linux || projectData.projectId; + const name = projectData.projectName; + const version = + this.$fs.readJson(path.join(projectData.projectDir, "package.json")) + ?.version || "1.0.0"; + const fill = (template: string, exec: string) => + template + .split("__APP_ID__") + .join(appId) + .split("__APP_NAME__") + .join(name) + .split("__APP_VERSION__") + .join(version) + .split("__APP_DATE__") + .join(new Date().toISOString().slice(0, 10)) + .split("__EXEC__") + .join(exec); + + const icon = ["icon.svg", "icon.png"] + .map((f) => path.join(resources, f)) + .find((f) => this.$fs.exists(f)); + if (icon) { + // an icon theme layout, so GTK and the desktop find it by app id + const size = path.extname(icon) === ".svg" ? "scalable" : "512x512"; + const icons = path.join(stage, "icons", "hicolor", size, "apps"); + this.$fs.ensureDirectoryExists(icons); + fs.copyFileSync(icon, path.join(icons, appId + path.extname(icon))); + } + + const desktopTemplate = path.join(resources, "app.desktop"); + const desktop = this.$fs.exists(desktopTemplate) + ? this.$fs.readText(desktopTemplate) + : `[Desktop Entry]\nType=Application\nName=__APP_NAME__\nExec=__EXEC__\nIcon=__APP_ID__\nTerminal=false\nStartupWMClass=__APP_ID__\n`; + // debug builds point at this folder, install.sh rewrites release entries + fs.writeFileSync( + path.join(stage, `${appId}.desktop`), + fill(desktop, release ? "__EXEC__" : path.join(stage, name)), + ); + + const metainfo = path.join(resources, "app.metainfo.xml"); + if (this.$fs.exists(metainfo)) { + fs.writeFileSync( + path.join(stage, `${appId}.metainfo.xml`), + fill(this.$fs.readText(metainfo), name), + ); + } + + if (release) { + const script = path.join(stage, "install.sh"); + fs.writeFileSync( + script, + [ + "#!/bin/sh", + "# Installs the app for the current user (~/.local).", + "set -e", + 'src="$(cd "$(dirname "$0")" && pwd)"', + 'data="${XDG_DATA_HOME:-$HOME/.local/share}"', + `dest="$data/${appId}"`, + 'rm -rf "$dest" && mkdir -p "$dest" "$data/applications" "$data/icons"', + 'cp -r "$src/." "$dest/"', + `sed "s|__EXEC__|$dest/${name}|g" "$src/${appId}.desktop" > "$data/applications/${appId}.desktop"`, + '[ -d "$src/icons" ] && cp -r "$src/icons/." "$data/icons/"', + `echo "Installed ${name} to $dest"`, + "", + ].join("\n"), + ); + fs.chmodSync(script, 0o755); + } + } + + /** + * Compiles app / plugin native code into `stage` (lib/, metadata/). Typings + * refresh `typings/linux` once `ns typings linux` created it. + */ + public async buildNativeCode( + projectData: IProjectData, + stage: string, + release: boolean, + typingsDir?: string, + ): Promise { + const arch = this.arch(); + const projectRoot = this.getPlatformData(projectData).projectRoot; + const projectTypings = path.join( + projectData.projectDir, + "typings", + "linux", + ); + await new LinuxNativeCodeBuilder(this.$childProcess, this.$logger).build( + await this.nativeUnits(projectData), + { + arch, + release, + stage, + workDir: path.join(projectRoot, "build", "native"), + generatorDir: this.toolsDir(projectData, arch), + blacklist: path.join( + projectRoot, + constants.MetadataFilteringConstants.BLACKLIST_FILE_NAME, + ), + typingsDir: + typingsDir ?? + (this.$fs.exists(projectTypings) + ? projectTypings + : path.join(projectRoot, "typings")), + }, + ); + if (!typingsDir && this.$fs.exists(projectTypings)) { + writeTypingsIndex(projectTypings); + } + } + + /** Typings of the SDK C APIs (`index.d.ts` references the GUI modules). */ + public sdkTypingsDir(projectData: IProjectData): string { + return path.join( + this.toolsDir(projectData, this.arch()), + "..", + "..", + "typings", + ); + } + + /** Build tools (metadata generator, filter) stay in the runtime package. */ + private toolsDir(projectData: IProjectData, arch: string): string { + const runtimePackage = this.$projectDataService.getRuntimePackage( + projectData.projectDir, + constants.PlatformTypes.linux, + ); + return path.join( + projectData.projectDir, + constants.NODE_MODULES_FOLDER_NAME, + runtimePackage.name, + "framework", + "tools", + arch, + ); + } + + /** App_Resources/Linux and each plugin's platforms/linux with native code. */ + private async nativeUnits( + projectData: IProjectData, + ): Promise { + const units: LinuxNativeUnit[] = []; + const resources = ["Linux", "linux"] + .map((d) => + projectData.appResourcesDirectoryPath + ? path.join(projectData.appResourcesDirectoryPath, d) + : null, + ) + .find((d) => d && this.$fs.exists(d)); + if (resources && hasNativeCode(resources)) { + units.push({ name: "app", dir: resources }); + } + const pluginsService: IPluginsService = injector.resolve("pluginsService"); + for (const plugin of await pluginsService.getAllInstalledPlugins( + projectData, + )) { + const dir = path.join( + plugin.fullPath, + constants.PLATFORMS_DIR_NAME, + LinuxProjectService.PLATFORM_NAME, + ); + if (hasNativeCode(dir)) { + units.push({ name: nativeUnitName(plugin.name), dir }); + } + } + return units; + } + + private appProject(projectData: IProjectData): string { + return path.join( + this.getPlatformData(projectData).projectRoot, + projectData.projectName, + ); + } + + private arch(): string { + const arch = (this.$options as any).arch || process.arch; + if (!RUNTIME_ARCH[arch]) { + throw new Error( + `Unsupported Linux architecture '${arch}', expected x64 or arm64.`, + ); + } + return arch; + } +} + +injector.register("linuxProjectService", LinuxProjectService); diff --git a/lib/services/livesync/linux-device-livesync-service.ts b/lib/services/livesync/linux-device-livesync-service.ts new file mode 100644 index 0000000000..f8820515bb --- /dev/null +++ b/lib/services/livesync/linux-device-livesync-service.ts @@ -0,0 +1,79 @@ +import * as fs from "fs"; +import { DeviceLiveSyncServiceBase } from "./device-livesync-service-base"; +import { IPlatformsDataService } from "../../definitions/platform"; +import { IProjectData } from "../../definitions/project"; +import { LinuxApplicationManager } from "../../common/mobile/linux/linux-application-manager"; + +export class LinuxDeviceLiveSyncService + extends DeviceLiveSyncServiceBase + implements INativeScriptDeviceLiveSyncService +{ + constructor( + protected platformsDataService: IPlatformsDataService, + protected device: Mobile.IDevice, + private $logger: ILogger, + ) { + super(platformsDataService, device); + } + + private get applicationManager(): LinuxApplicationManager { + return this.device.applicationManager as LinuxApplicationManager; + } + + public async restartApplication( + projectData: IProjectData, + liveSyncInfo: ILiveSyncResultInfo, + ): Promise { + this.$logger.info( + `[Linux LiveSync] Restarting application ${projectData.projectName}`, + ); + await this.applicationManager.restartApplication({ + appId: projectData.projectIdentifiers?.linux ?? projectData.projectId, + projectName: projectData.projectName, + projectDir: projectData.projectDir, + waitForDebugger: liveSyncInfo?.waitForDebugger, + debugMode: liveSyncInfo?.debugMode, + }); + } + + public async shouldRestart( + _projectData: IProjectData, + liveSyncInfo: ILiveSyncResultInfo, + ): Promise { + return ( + !liveSyncInfo.useHotModuleReload || + !!liveSyncInfo.isFullSync || + !!liveSyncInfo.waitForDebugger + ); + } + + public async tryRefreshApplication( + projectData: IProjectData, + liveSyncInfo: ILiveSyncResultInfo, + ): Promise { + if ( + !liveSyncInfo.useHotModuleReload || + !this.applicationManager.isRunning() + ) { + return false; + } + // vite: the app pulls changes from the dev server itself + if (projectData.bundler === "vite") { + return true; + } + // webpack: the hot-update files are synced, the runtime applies them on SIGUSR1 + return this.applicationManager.signalLiveSync(); + } + + public async removeFiles( + _deviceAppData: Mobile.IDeviceAppData, + localToDevicePaths: Mobile.ILocalToDevicePathData[], + ): Promise { + for (const pathData of localToDevicePaths) { + const devicePath = pathData.getDevicePath(); + if (fs.existsSync(devicePath)) { + fs.unlinkSync(devicePath); + } + } + } +} diff --git a/lib/services/livesync/linux-livesync-service.ts b/lib/services/livesync/linux-livesync-service.ts new file mode 100644 index 0000000000..e123f8c680 --- /dev/null +++ b/lib/services/livesync/linux-livesync-service.ts @@ -0,0 +1,44 @@ +import { PlatformLiveSyncServiceBase } from "./platform-livesync-service-base"; +import { LinuxDeviceLiveSyncService } from "./linux-device-livesync-service"; +import { IPlatformsDataService } from "../../definitions/platform"; +import { IProjectData } from "../../definitions/project"; +import { IProjectFilesManager, IFileSystem } from "../../common/declarations"; +import { IInjector } from "../../common/definitions/yok"; +import { IOptions } from "../../declarations"; +import { injector } from "../../common/yok"; + +export class LinuxLiveSyncService + extends PlatformLiveSyncServiceBase + implements IPlatformLiveSyncService +{ + constructor( + protected $platformsDataService: IPlatformsDataService, + protected $projectFilesManager: IProjectFilesManager, + private $injector: IInjector, + $devicePathProvider: IDevicePathProvider, + $fs: IFileSystem, + $logger: ILogger, + $options: IOptions, + ) { + super( + $fs, + $logger, + $platformsDataService, + $projectFilesManager, + $devicePathProvider, + $options, + ); + } + + protected _getDeviceLiveSyncService( + device: Mobile.IDevice, + _data: IProjectData, + _frameworkVersion: string, + ): INativeScriptDeviceLiveSyncService { + return this.$injector.resolve( + LinuxDeviceLiveSyncService, + { device, platformsDataService: this.$platformsDataService }, + ); + } +} +injector.register("linuxLiveSyncService", LinuxLiveSyncService); diff --git a/lib/services/platforms-data-service.ts b/lib/services/platforms-data-service.ts index 7383114a50..60f8500bbb 100644 --- a/lib/services/platforms-data-service.ts +++ b/lib/services/platforms-data-service.ts @@ -12,12 +12,14 @@ export class PlatformsDataService implements IPlatformsDataService { $androidProjectService: IPlatformProjectService, $iOSProjectService: IPlatformProjectService, $windowsProjectService: IPlatformProjectService, + $linuxProjectService: IPlatformProjectService, ) { this.platformsDataService = { ios: $iOSProjectService, android: $androidProjectService, visionos: $iOSProjectService, windows: $windowsProjectService, + linux: $linuxProjectService, }; } diff --git a/lib/services/prepare-data-service.ts b/lib/services/prepare-data-service.ts index a10f06aee1..ff3fbeece1 100644 --- a/lib/services/prepare-data-service.ts +++ b/lib/services/prepare-data-service.ts @@ -2,6 +2,7 @@ import { IOSPrepareData, AndroidPrepareData, WindowsPrepareData, + LinuxPrepareData, } from "../data/prepare-data"; import { injector } from "../common/yok"; import { IOptions } from "../declarations"; @@ -18,6 +19,8 @@ export class PrepareDataService implements IPrepareDataService { return new AndroidPrepareData(projectDir, platformLowerCase, data); } else if (this.$mobileHelper.isWindowsPlatform(platform)) { return new WindowsPrepareData(projectDir, platformLowerCase, data); + } else if (this.$mobileHelper.isLinuxPlatform(platform)) { + return new LinuxPrepareData(projectDir, platformLowerCase, data); } } } diff --git a/lib/services/project-data-service.ts b/lib/services/project-data-service.ts index 28e9851aaa..92f676df17 100644 --- a/lib/services/project-data-service.ts +++ b/lib/services/project-data-service.ts @@ -655,6 +655,11 @@ export class ProjectDataService implements IProjectDataService { constants.SCOPED_WINDOWS_QUICKJS_RUNTIME_NAME, constants.SCOPED_WINDOWS_JSC_RUNTIME_NAME, ].includes(d.name); + } else if (platform === constants.PlatformTypes.linux) { + return [ + constants.SCOPED_LINUX_RUNTIME_NAME, + constants.SCOPED_LINUX_QUICKJS_RUNTIME_NAME, + ].includes(d.name); } }); @@ -725,6 +730,11 @@ export class ProjectDataService implements IProjectDataService { name: constants.SCOPED_WINDOWS_RUNTIME_NAME, version: null, }; + } else if (platform === constants.PlatformTypes.linux) { + return { + name: constants.SCOPED_LINUX_RUNTIME_NAME, + version: null, + }; } } diff --git a/packages/doctor/src/constants.ts b/packages/doctor/src/constants.ts index a16814f29f..ada0cf2b8d 100644 --- a/packages/doctor/src/constants.ts +++ b/packages/doctor/src/constants.ts @@ -2,10 +2,12 @@ export class Constants { public static ANDROID_PLATFORM_NAME = "Android"; public static IOS_PLATFORM_NAME = "iOS"; public static WINDOWS_PLATFORM_NAME = "Windows"; + public static LINUX_PLATFORM_NAME = "Linux"; public static SUPPORTED_PLATFORMS = [ Constants.ANDROID_PLATFORM_NAME, Constants.IOS_PLATFORM_NAME, Constants.WINDOWS_PLATFORM_NAME, + Constants.LINUX_PLATFORM_NAME, ]; public static SYSTEM_REQUIREMENTS_LINKS = "https://docs.nativescript.org/setup"; @@ -22,6 +24,8 @@ export class Constants { public static DOTNET_SDK_MIN_REQUIRED_VERSION = 10; public static WINDOWS_SYSTEM_REQUIREMENTS_LINK = "https://docs.nativescript.org/setup/windows#setting-up-windows-for-windows-desktop"; + public static LINUX_SYSTEM_REQUIREMENTS_LINK = + "https://docs.nativescript.org/setup/linux#setting-up-linux-for-linux-desktop"; public static JAVAC_EXECUTABLE_NAME = "javac"; public static JAVA_EXECUTABLE_NAME = "java"; } diff --git a/packages/doctor/src/doctor.ts b/packages/doctor/src/doctor.ts index dc10accae3..e42f535f49 100644 --- a/packages/doctor/src/doctor.ts +++ b/packages/doctor/src/doctor.ts @@ -4,6 +4,7 @@ import { HostInfo } from "./host-info"; import { AndroidLocalBuildRequirements } from "./local-build-requirements/android-local-build-requirements"; import { IosLocalBuildRequirements } from "./local-build-requirements/ios-local-build-requirements"; import { WindowsLocalBuildRequirements } from "./local-build-requirements/windows-local-build-requirements"; +import { LinuxLocalBuildRequirements } from "./local-build-requirements/linux-local-build-requirements"; import * as semver from "semver"; export class Doctor implements NativeScriptDoctor.IDoctor { @@ -16,6 +17,7 @@ export class Doctor implements NativeScriptDoctor.IDoctor { private sysInfo: NativeScriptDoctor.ISysInfo, private androidToolsInfo: NativeScriptDoctor.IAndroidToolsInfo, private windowsLocalBuildRequirements: WindowsLocalBuildRequirements, + private linuxLocalBuildRequirements?: LinuxLocalBuildRequirements, ) {} public async canExecuteLocalBuild( @@ -40,6 +42,10 @@ export class Doctor implements NativeScriptDoctor.IDoctor { platform.toLowerCase() === Constants.WINDOWS_PLATFORM_NAME.toLowerCase() ) { return await this.windowsLocalBuildRequirements.checkRequirements(); + } else if ( + platform.toLowerCase() === Constants.LINUX_PLATFORM_NAME.toLowerCase() + ) { + return !!(await this.linuxLocalBuildRequirements?.checkRequirements()); } return false; @@ -84,6 +90,15 @@ export class Doctor implements NativeScriptDoctor.IDoctor { result = result.concat(this.getWindowsInfos(sysInfoData)); } + if ( + !config || + !config.platform || + config.platform.toLowerCase() === + Constants.LINUX_PLATFORM_NAME.toLowerCase() + ) { + result = result.concat(this.getLinuxInfos(sysInfoData)); + } + if (!this.hostInfo.isDarwin) { result.push({ message: @@ -303,6 +318,34 @@ export class Doctor implements NativeScriptDoctor.IDoctor { return result; } + private getLinuxInfos( + sysInfoData: NativeScriptDoctor.ISysInfoData, + ): NativeScriptDoctor.IInfo[] { + if (!this.hostInfo.isLinux) { + return [ + { + message: + "Local builds for Linux can be executed only on a Linux system.", + additionalInformation: "", + platforms: [Constants.LINUX_PLATFORM_NAME], + type: Constants.INFO_TYPE_NAME, + }, + ]; + } + return [ + this.processSysInfoItem({ + item: sysInfoData.isGtk4Installed, + infoMessage: "GTK 4 is installed.", + warningMessage: "GTK 4 is not installed.", + additionalInformation: + `You will not be able to run your projects on this machine.${EOL}` + + `Install GTK 4 (e.g. \`sudo apt install libgtk-4-1\` or \`sudo dnf install gtk4\`).${EOL}` + + `For more information visit: ${Constants.LINUX_SYSTEM_REQUIREMENTS_LINK}`, + platforms: [Constants.LINUX_PLATFORM_NAME], + }), + ]; + } + private getWindowsInfos( sysInfoData: NativeScriptDoctor.ISysInfoData, ): NativeScriptDoctor.IInfo[] { @@ -312,8 +355,7 @@ export class Doctor implements NativeScriptDoctor.IDoctor { const minDotNetSdkVer = Constants.DOTNET_SDK_MIN_REQUIRED_VERSION; result = result.concat( this.processSysInfoItem({ - item: - !!dotNetSdkVer && semver.major(dotNetSdkVer) >= minDotNetSdkVer, + item: !!dotNetSdkVer && semver.major(dotNetSdkVer) >= minDotNetSdkVer, infoMessage: `The .NET SDK ${dotNetSdkVer} is installed.`, warningMessage: dotNetSdkVer ? `The .NET SDK ${dotNetSdkVer} is older than the required version ${minDotNetSdkVer}.` diff --git a/packages/doctor/src/index.ts b/packages/doctor/src/index.ts index ac611bdc08..4c726b395d 100644 --- a/packages/doctor/src/index.ts +++ b/packages/doctor/src/index.ts @@ -9,6 +9,7 @@ import { Doctor } from "./doctor"; import { AndroidLocalBuildRequirements } from "./local-build-requirements/android-local-build-requirements"; import { IosLocalBuildRequirements } from "./local-build-requirements/ios-local-build-requirements"; import { WindowsLocalBuildRequirements } from "./local-build-requirements/windows-local-build-requirements"; +import { LinuxLocalBuildRequirements } from "./local-build-requirements/linux-local-build-requirements"; import { Constants as constants } from "./constants"; const childProcess = new ChildProcess(); @@ -42,7 +43,11 @@ const iOSLocalBuildRequirements = new IosLocalBuildRequirements( ); const windowsLocalBuildRequirements = new WindowsLocalBuildRequirements( sysInfo, - hostInfo + hostInfo, +); +const linuxLocalBuildRequirements = new LinuxLocalBuildRequirements( + sysInfo, + hostInfo, ); const doctor: NativeScriptDoctor.IDoctor = new Doctor( @@ -52,6 +57,7 @@ const doctor: NativeScriptDoctor.IDoctor = new Doctor( sysInfo, androidToolsInfo, windowsLocalBuildRequirements, + linuxLocalBuildRequirements, ); const setShouldCacheSysInfo = sysInfo.setShouldCacheSysInfo.bind(sysInfo); diff --git a/packages/doctor/src/local-build-requirements/linux-local-build-requirements.ts b/packages/doctor/src/local-build-requirements/linux-local-build-requirements.ts new file mode 100644 index 0000000000..f89fa0988a --- /dev/null +++ b/packages/doctor/src/local-build-requirements/linux-local-build-requirements.ts @@ -0,0 +1,19 @@ +import { Constants } from "../constants"; +import { HostInfo } from "../host-info"; + +export class LinuxLocalBuildRequirements { + constructor( + private sysInfo: NativeScriptDoctor.ISysInfo, + private hostInfo: HostInfo, + ) {} + + public async checkRequirements(): Promise { + if (!this.hostInfo.isLinux) { + return false; + } + const sysInfoData = await this.sysInfo.getSysInfo({ + platform: Constants.LINUX_PLATFORM_NAME, + }); + return !!sysInfoData.isGtk4Installed; + } +} diff --git a/packages/doctor/src/sys-info.ts b/packages/doctor/src/sys-info.ts index 0f986459dd..d920be7044 100644 --- a/packages/doctor/src/sys-info.ts +++ b/packages/doctor/src/sys-info.ts @@ -44,6 +44,7 @@ export class SysInfo implements NativeScriptDoctor.ISysInfo { private androidSysInfoCache: NativeScriptDoctor.IAndroidSysInfoData; private iOSSysInfoCache: NativeScriptDoctor.IiOSSysInfoData; private windowsSysInfoCache: NativeScriptDoctor.IWindowsSysInfoData; + private linuxSysInfoCache: NativeScriptDoctor.ILinuxSysInfoData; private dotNetSdkVerCache: string; private isWindowsDeveloperModeEnabledCache: boolean; @@ -389,6 +390,20 @@ export class SysInfo implements NativeScriptDoctor.ISysInfo { ); } + if ( + config && + config.platform && + config.platform.toLowerCase() === + Constants.LINUX_PLATFORM_NAME.toLowerCase() + ) { + return ( + Object.assign( + await this.getCommonSysInfo(), + await this.getLinuxSysInfo(), + ) + ); + } + if ( config && config.platform && @@ -411,6 +426,9 @@ export class SysInfo implements NativeScriptDoctor.ISysInfo { if (this.hostInfo.isWindows) { sysInfoParts.push(await this.getWindowsSysInfo()); } + if (this.hostInfo.isLinux) { + sysInfoParts.push(await this.getLinuxSysInfo()); + } return Object.assign({}, ...sysInfoParts); } @@ -789,6 +807,24 @@ export class SysInfo implements NativeScriptDoctor.ISysInfo { ); } + private async getLinuxSysInfo(): Promise { + if (this.linuxSysInfoCache === undefined) { + this.linuxSysInfoCache = { + isGtk4Installed: await this.isGtk4Installed(), + }; + } + return this.linuxSysInfoCache; + } + + // GTK apps only need the shared library, not the -dev package. + private async isGtk4Installed(): Promise { + if (!this.hostInfo.isLinux) { + return false; + } + const output = await this.execCommand("ldconfig -p"); + return !!output && output.includes("libgtk-4.so.1"); + } + private getWindowsSysInfo(): Promise { return this.getValueForProperty( () => this.windowsSysInfoCache, diff --git a/packages/doctor/typings/interfaces.d.ts b/packages/doctor/typings/interfaces.d.ts index fae10c5861..32489effc2 100644 --- a/packages/doctor/typings/interfaces.d.ts +++ b/packages/doctor/typings/interfaces.d.ts @@ -380,11 +380,21 @@ declare module NativeScriptDoctor { isWindowsDeveloperModeEnabled?: boolean; } + interface ILinuxSysInfoData { + /** + * Whether GTK 4 (libgtk-4.so.1) is installed. Valid only on Linux. + * @type {boolean} + */ + isGtk4Installed?: boolean; + } + interface ISysInfoData - extends ICommonSysInfoData, + extends + ICommonSysInfoData, IiOSSysInfoData, IAndroidSysInfoData, - IWindowsSysInfoData {} + IWindowsSysInfoData, + ILinuxSysInfoData {} /** * Describes warning returned from @nativescript/doctor check. diff --git a/test/controllers/debug-controller.ts b/test/controllers/debug-controller.ts index 79cb1c69ee..dfef37c4ce 100644 --- a/test/controllers/debug-controller.ts +++ b/test/controllers/debug-controller.ts @@ -113,6 +113,8 @@ describe("debugController", () => { testInjector.register("windowsDeviceDebugService", PlatformDebugService); + testInjector.register("linuxDeviceDebugService", PlatformDebugService); + testInjector.register("mobileHelper", { isAndroidPlatform: (platform: string) => { return platform.toLowerCase() === "android"; @@ -123,6 +125,9 @@ describe("debugController", () => { isWindowsPlatform: (platform: string) => { return platform.toLowerCase() === "windows"; }, + isLinuxPlatform: (platform: string) => { + return platform.toLowerCase() === "linux"; + }, }); testInjector.register("errors", stubs.ErrorsStub); @@ -264,10 +269,14 @@ describe("debugController", () => { it("windowsDeviceDebugService's debug method fails", async () => { await assertIsRejectedWhenPlatformDebugServiceFails("windows"); }); + + it("linuxDeviceDebugService's debug method fails", async () => { + await assertIsRejectedWhenPlatformDebugServiceFails("linux"); + }); }); describe(`raises ${CONNECTION_ERROR_EVENT_NAME} event`, () => { - _.each(["android", "iOS", "windows"], (platform) => { + _.each(["android", "iOS", "windows", "linux"], (platform) => { it(`when ${platform}DebugService raises ${CONNECTION_ERROR_EVENT_NAME} event`, async () => { const testData = getDefaultTestData(); testData.deviceInformation.deviceInfo.platform = platform; diff --git a/test/ios-project-service.ts b/test/ios-project-service.ts index ac94a3dd90..16e96108a7 100644 --- a/test/ios-project-service.ts +++ b/test/ios-project-service.ts @@ -132,6 +132,7 @@ function createTestInjector( public async startLookingForDevices(): Promise {} } testInjector.register("windowsDeviceDiscovery", WindowsDeviceDiscoveryStub); + testInjector.register("linuxDeviceDiscovery", WindowsDeviceDiscoveryStub); testInjector.register("messages", Messages); testInjector.register("mobileHelper", MobileHelper); testInjector.register("deviceLogProvider", DeviceLogProvider); diff --git a/test/plugins-service.ts b/test/plugins-service.ts index 0c68d3a99d..76f8893107 100644 --- a/test/plugins-service.ts +++ b/test/plugins-service.ts @@ -110,6 +110,7 @@ function createTestInjector() { testInjector.register("androidProjectService", AndroidProjectService); testInjector.register("iOSProjectService", {}); testInjector.register("windowsProjectService", {}); + testInjector.register("linuxProjectService", {}); testInjector.register("devicesService", {}); testInjector.register("projectDataService", ProjectDataService); testInjector.register("prompter", {}); diff --git a/test/project-changes-service.ts b/test/project-changes-service.ts index 7615a38103..64ffb778fb 100644 --- a/test/project-changes-service.ts +++ b/test/project-changes-service.ts @@ -36,6 +36,7 @@ class ProjectChangesServiceTest extends BaseServiceTest { this.injector.register("androidProjectService", {}); this.injector.register("iOSProjectService", {}); this.injector.register("windowsProjectService", {}); + this.injector.register("linuxProjectService", {}); this.injector.register("fs", FileSystem); this.injector.register("devicePlatformsConstants", {}); this.injector.register("devicePlatformsConstants", {}); diff --git a/test/services/linux-application-manager.ts b/test/services/linux-application-manager.ts new file mode 100644 index 0000000000..cee03ad12b --- /dev/null +++ b/test/services/linux-application-manager.ts @@ -0,0 +1,104 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "chai"; +import { LinuxApplicationManager } from "../../lib/common/mobile/linux/linux-application-manager"; +import * as stubs from "../stubs"; + +const isLinux = process.platform === "linux"; + +function waitFor(check: () => boolean, ms = 5000): Promise { + const deadline = Date.now() + ms; + return new Promise((resolve, reject) => { + const tick = () => { + if (check()) return resolve(); + if (Date.now() > deadline) return reject(new Error("timed out")); + setTimeout(tick, 20); + }; + tick(); + }); +} + +describe.runIf(isLinux)("LinuxApplicationManager", () => { + let dir: string; + let lines: string[]; + let manager: LinuxApplicationManager; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-linux-app-")); + lines = []; + const logProvider = { + logData: (line: string) => lines.push(line), + setSourceFileLocation: async () => {}, + }; + manager = new LinuxApplicationManager( + new stubs.LoggerStub(), + {}, + logProvider, + "host", + ); + }); + + afterEach(async () => { + await manager.stopApplication({}); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function app(script: string): string { + // stands in for the runtime: prints its args, reacts to SIGUSR1 + const executable = path.join(dir, "demo"); + fs.writeFileSync(executable, `#!/bin/sh\n${script}\n`); + return executable; + } + + it("runs the installed executable and forwards its output", async () => { + await manager.installApplication( + app('echo "args:$*"; echo oops >&2; sleep 30'), + "org.demo", + ); + assert.isTrue(await manager.isApplicationInstalled("org.demo")); + await manager.startApplication({ + appId: "org.demo", + projectName: "demo", + debugMode: true, + }); + await waitFor(() => lines.length >= 2); + assert.includeMembers(lines, [ + `args:--inspect=${manager.inspectorPort}`, + "oops", + ]); + assert.isTrue(manager.isRunning()); + await manager.stopApplication({}); + assert.isFalse(manager.isRunning()); + }); + + it("signals the running app to apply livesync changes", async () => { + await manager.installApplication( + app( + 'trap "echo synced" USR1; echo ready; while true; do sleep 0.05; done', + ), + "org.demo", + ); + assert.isFalse(manager.signalLiveSync()); + await manager.startApplication({ + appId: "org.demo", + projectName: "demo", + }); + await waitFor(() => lines.includes("ready")); + assert.isTrue(manager.signalLiveSync()); + await waitFor(() => lines.includes("synced")); + }); + + it("fails to start an app that wasn't built", async () => { + let error: Error; + try { + await manager.startApplication({ + appId: "missing", + projectName: "missing", + }); + } catch (err) { + error = err; + } + assert.include(error?.message, "ns build linux"); + }); +}); diff --git a/test/services/linux-device-debug-service.ts b/test/services/linux-device-debug-service.ts new file mode 100644 index 0000000000..d3d4217239 --- /dev/null +++ b/test/services/linux-device-debug-service.ts @@ -0,0 +1,68 @@ +import * as net from "net"; +import { assert } from "chai"; +import { LinuxDeviceDebugService } from "../../lib/services/linux-device-debug-service"; +import * as stubs from "../stubs"; + +const appId = "org.nativescript.linuxapp"; + +function createService(port: number): LinuxDeviceDebugService { + const device = { + deviceInfo: { identifier: "host" }, + applicationManager: { inspectorPort: port }, + }; + return new LinuxDeviceDebugService( + device, + {}, + new stubs.ErrorsStub(), + new stubs.LoggerStub(), + ); +} + +function listen(): Promise { + return new Promise((resolve) => { + const server = net.createServer((socket) => socket.destroy()); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +describe("linuxDeviceDebugService", () => { + const debugData = { + applicationIdentifier: appId, + projectDir: "/tmp", + deviceIdentifier: "host", + }; + + it("returns the DevTools URL once the inspector listens", async () => { + const server = await listen(); + try { + const port = (server.address()).port; + const result = await createService(port).debug(debugData, { + timeout: "2", + }); + assert.equal( + result.debugUrl, + `devtools://devtools/bundled/inspector.html?ws=127.0.0.1:${port}`, + ); + } finally { + server.close(); + } + }); + + it("fails when nothing listens on the inspector port", async () => { + const server = await listen(); + const port = (server.address()).port; + await new Promise((resolve) => server.close(resolve)); + let error: Error; + try { + await createService(port).debug(debugData, { timeout: "1" }); + } catch (err) { + error = err; + } + assert.include(error?.message, "is not running with the debugger enabled"); + }); + + it("does not wait when stopping", async () => { + const result = await createService(1).debug(debugData, { stop: true }); + assert.isNull(result.debugUrl); + }); +}); diff --git a/test/services/linux-native-code.ts b/test/services/linux-native-code.ts new file mode 100644 index 0000000000..8b561c5380 --- /dev/null +++ b/test/services/linux-native-code.ts @@ -0,0 +1,217 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "chai"; +import { + LinuxNativeCodeBuilder, + nativeUnitName, + hasNativeCode, +} from "../../lib/services/linux-native-code"; +import * as stubs from "../stubs"; + +/** Records commands and fakes their outputs. */ +function fakeProcess() { + const calls: { command: string; args: string[]; cwd: string }[] = []; + return { + calls, + spawnFromEvent: async ( + command: string, + args: string[], + _event: string, + options: any, + ) => { + calls.push({ command, args, cwd: options.cwd }); + const output = (flag: string) => args[args.indexOf(flag) + 1]; + if (command === "pkg-config") { + return { + exitCode: 0, + stdout: + args[0] === "--cflags" + ? "-I/usr/include/glib-2.0\n" + : "-lglib-2.0\n", + stderr: "", + }; + } + if (command.endsWith("ns-metadata-filter")) { + fs.writeFileSync(args[1], "filtered"); + return { exitCode: 0, stdout: "1 of 2 declarations", stderr: "" }; + } + if (command.endsWith("metadata-generator")) { + fs.writeFileSync(output("-output-bin"), "bin"); + fs.mkdirSync(output("-output-typescript"), { recursive: true }); + fs.writeFileSync( + path.join(output("-output-typescript"), "c!app.d.ts"), + "", + ); + } else { + fs.writeFileSync(output("-o"), "elf"); + } + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }; +} + +describe("linuxNativeCode", () => { + let root: string; + let options: any; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "ns-linux-native-")); + const generatorDir = path.join(root, "tools"); + fs.mkdirSync(generatorDir); + fs.writeFileSync(path.join(generatorDir, "metadata-generator"), ""); + options = { + arch: "x64", + release: false, + stage: path.join(root, "stage"), + workDir: path.join(root, "work"), + generatorDir, + typingsDir: path.join(root, "typings"), + }; + }); + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + function unit(files: Record) { + const dir = path.join(root, "Linux"); + for (const [file, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), content); + } + return { name: "app", dir }; + } + + it("names units after plugins", () => { + assert.equal( + nativeUnitName("@nativescript/my-plugin"), + "nativescript_my_plugin", + ); + assert.equal(nativeUnitName("3d"), "_3d"); + }); + + it("compiles sources, generates metadata and stages both", async () => { + const app = unit({ + "src/hello.c": "int f(void);", + "src/hello.h": "int f(void);", + "native.json": '{"pkg-config":["glib-2.0"],"libs":["-lm"]}', + }); + assert.isTrue(hasNativeCode(app.dir)); + const proc = fakeProcess(); + await new LinuxNativeCodeBuilder(proc, new stubs.LoggerStub()).build( + [app], + options, + ); + + const cc = proc.calls.find((c) => c.command === "cc"); + assert.includeMembers(cc.args, [ + "-shared", + "-fPIC", + "-Wl,-soname,libapp.so", + "-I/usr/include/glib-2.0", + "-lglib-2.0", + "-lm", + path.join(app.dir, "src/hello.c"), + ]); + const generator = proc.calls.find((c) => + c.command.endsWith("metadata-generator"), + ); + assert.equal(generator.cwd, options.generatorDir); + const modulemap = fs.readFileSync( + path.join(options.workDir, "app/gen/module.modulemap"), + "utf8", + ); + assert.include(modulemap, `umbrella "${path.join(app.dir, "src")}"`); + assert.include(modulemap, 'link "libapp.so"'); + + assert.isTrue(fs.existsSync(path.join(options.stage, "lib/libapp.so"))); + assert.isTrue(fs.existsSync(path.join(options.stage, "metadata/app.bin"))); + assert.isTrue(fs.existsSync(path.join(options.typingsDir, "c!app.d.ts"))); + }); + + it("reuses the previous build when nothing changed", async () => { + const app = unit({ "src/hello.c": "", "src/hello.h": "" }); + const proc = fakeProcess(); + const builder = new LinuxNativeCodeBuilder( + proc, + new stubs.LoggerStub(), + ); + await builder.build([app], options); + const count = proc.calls.length; + fs.rmSync(options.stage, { recursive: true }); + await builder.build([app], options); + assert.equal(proc.calls.length, count); + assert.isTrue(fs.existsSync(path.join(options.stage, "lib/libapp.so"))); + }); + + it("bundles prebuilt shared libraries and links them from public headers", async () => { + const app = unit({ + "include/foo.h": "void foo(void);", + "lib/x64/libfoo.so.1": "elf", + "lib/arm64/libfoo.so.1": "elf", + }); + const proc = fakeProcess(); + await new LinuxNativeCodeBuilder(proc, new stubs.LoggerStub()).build( + [app], + options, + ); + assert.isUndefined(proc.calls.find((c) => c.command === "cc")); + assert.deepEqual(fs.readdirSync(path.join(options.stage, "lib")), [ + "libfoo.so.1", + ]); + const modulemap = fs.readFileSync( + path.join(options.workDir, "app/gen/module.modulemap"), + "utf8", + ); + assert.include(modulemap, `umbrella "${path.join(app.dir, "include")}"`); + assert.include(modulemap, 'link "libfoo.so.1"'); + }); + + it("copies SDK metadata unless there are filters", async () => { + const input = path.join(root, "metadata-x86_64.bin"); + fs.writeFileSync(input, "sdk"); + fs.writeFileSync(path.join(options.generatorDir, "ns-metadata-filter"), ""); + const filterOptions = { + toolsDir: options.generatorDir, + cacheDir: path.join(root, "cache"), + whitelist: path.join(root, "whitelist.mdg"), + blacklist: path.join(root, "blacklist.mdg"), + }; + const proc = fakeProcess(); + const builder = new LinuxNativeCodeBuilder( + proc, + new stubs.LoggerStub(), + ); + const output = path.join(root, "out.bin"); + + await builder.filterMetadata(input, output, filterOptions); + assert.equal(fs.readFileSync(output, "utf8"), "sdk"); + assert.lengthOf(proc.calls, 0); + + fs.writeFileSync(filterOptions.whitelist, "Gtk:gtk_*"); + await builder.filterMetadata(input, output, filterOptions); + assert.lengthOf(proc.calls, 1); + assert.deepEqual(proc.calls[0].args.slice(2), [ + "--whitelist", + filterOptions.whitelist, + ]); + // same inputs reuse the filtered file + await builder.filterMetadata(input, output, filterOptions); + assert.lengthOf(proc.calls, 1); + }); + + it("passes the blacklist to the generator", async () => { + const app = unit({ "include/foo.h": "void foo(void);" }); + const blacklist = path.join(root, "blacklist.mdg"); + fs.writeFileSync(blacklist, "app:foo_private*"); + const proc = fakeProcess(); + await new LinuxNativeCodeBuilder(proc, new stubs.LoggerStub()).build( + [app], + { ...options, blacklist }, + ); + const generator = proc.calls.find((c) => + c.command.endsWith("metadata-generator"), + ); + assert.include(generator.args, "-blacklist-modules-file"); + assert.include(generator.args, blacklist); + }); +});