Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/man_pages/project/configuration/widget.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
<% if (isJekyll) { %>---
title: ns widget ios
title: ns widget
position: 11
---<% } %>

# ns widget ios
# ns widget

### Description

Interactively adds a new iOS widget based on a predefined template.
Interactively adds a new iOS/Android widget based on a predefined template.

### Commands

Usage | Synopsis
------|-------
General | `$ ns widget ios`
General | `$ ns widget <Platform>`

<% if(isHtml) { %>

Expand Down
5 changes: 4 additions & 1 deletion lib/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,5 +479,8 @@ injector.requireCommand(
],
"./commands/native-add",
);
injector.requireCommand(["widget", "widget|ios"], "./commands/widget");
injector.requireCommand(
["widget", "widget|ios", "widget|android"],
"./commands/widget",
);
require("./key-commands/bootstrap");
346 changes: 346 additions & 0 deletions lib/commands/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,352 @@ declare class AppleWidgetUtils extends NSObject {
}
}
}
export class WidgetAndroidCommand extends WidgetCommand {
constructor(
$projectData: IProjectData,
$projectConfigService: IProjectConfigService,
$logger: ILogger,
$errors: IErrors,
) {
super($projectData, $projectConfigService, $logger, $errors);
}
public async canExecute(args: string[]): Promise<boolean> {
return true;
}

public async execute(args: string[]): Promise<void> {
this.startPrompt(args);
}
Comment on lines +953 to +955

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="lib/commands/widget.ts"

echo "== line count =="
wc -l "$FILE"

echo
echo "== execute/startPrompt context =="
grep -n -A8 -B8 -E 'async execute\(|startPrompt\(' "$FILE"

Repository: NativeScript/nativescript-cli

Length of output: 2426


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find similar async execute patterns and whether they await internal async helpers.
rg -n "public async execute\([^)]*\): Promise<void> \{\s*(await )?this\.[A-Za-z0-9_]+\(" lib/commands -U

echo
echo "== widget startPrompt body =="
sed -n '978,1140p' lib/commands/widget.ts | cat -n

Repository: NativeScript/nativescript-cli

Length of output: 7209


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== execute call sites =="
rg -n "\.execute\(" lib src test -g '!**/node_modules/**'

echo
echo "== command runner / dispatcher references =="
rg -n "canExecute\(|executeCore\(|runCommand|dispatch|command.*execute" lib src test -g '!**/node_modules/**'

Repository: NativeScript/nativescript-cli

Length of output: 50387


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== commands service context =="
sed -n '90,125p' lib/common/services/commands-service.ts | cat -n

echo
echo "== widget command context =="
sed -n '949,985p' lib/commands/widget.ts | cat -n

Repository: NativeScript/nativescript-cli

Length of output: 2741


Await startPrompt in execute().

commands-service.ts awaits command.execute(...), but this method returns before the prompt/generation chain finishes, so the widget command can complete early and miss downstream errors.

Suggested fix
 public async execute(args: string[]): Promise<void> {
-	this.startPrompt(args);
+	await this.startPrompt(args);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public async execute(args: string[]): Promise<void> {
this.startPrompt(args);
}
public async execute(args: string[]): Promise<void> {
await this.startPrompt(args);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/commands/widget.ts` around lines 953 - 955, The widget command’s execute
method returns before the prompt flow finishes, because startPrompt is not
awaited. Update execute in widget.ts so it awaits startPrompt(args), ensuring
commands-service waits for the full prompt/generation chain and surfaces
downstream errors from the widget command.


private toAndroidFriendlyResourceName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]/g, "_") // replace anything not a-z, 0-9, or _ with _
.replace(/_{2,}/g, "_") // collapse multiple underscores
.replace(/^[0-9_]+/, ""); // strip leading digits or underscores
}

private toAndroidClassName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]/g, "_") // normalize to resource name first
.replace(/_{2,}/g, "_")
.replace(/^[0-9_]+/, "")
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
}

private async startPrompt(args: string[]) {
let result = await prompts.prompt({
type: "text",
name: "name",
message: `What name would you like for this widget? (Default is 'widget')`,
});

const rawName = (result.name || "widget").toLowerCase();

const name = this.toAndroidFriendlyResourceName(rawName);

result = await prompts.prompt({
type: "text",
name: "description",
message: `What description would you like for this widget? (Default is '')`,
});

const description = result.description || "";

result = await prompts.prompt({
type: "number",
name: "updateInterval",
message: `What update interval would you like for this widget? (Default is 900000 ms or 15 mins)`,
});

const updateInterval = result.updateInterval || 900000;

result = await prompts.prompt({
type: "select",
name: "resizeMode",
message: `What type of resizing would you like for this widget?`,
choices: [
{
title: "Horizontal",
description:
"This will allow the widget to resize horizontally on the Home Screen",
value: 0,
},
{
title: "Vertical",
description:
"This will allow the widget to resize vertically on the Home Screen",
value: 1,
},
{
title: "Horizontal and Vertical",
description:
"This will allow the widget to resize both horizontally and vertically on the Home Screen",
value: 2,
},
],
initial: 2,
});

let resizeMode = "horizontal|vertical";

switch (result.resizeMode) {
case 0:
resizeMode = "horizontal";
break;
case 1:
resizeMode = "vertical";
break;
case 2:
resizeMode = "horizontal|vertical";
break;
}

result = await prompts.prompt({
type: "text",
name: "minWidth",
message: `What minimum width would you like for this widget? (Default is '50dp')`,
});

const minWidth = result.minWidth || "50dp";

result = await prompts.prompt({
type: "text",
name: "minHeight",
message: `What minimum height would you like for this widget? (Default is '50dp')`,
});

const minHeight = result.minHeight || "50dp";

result = await prompts.prompt({
type: "text",
name: "initialLayout",
message: `What initial layout would you like for this widget? (Default is 'ns_remote_views_linear_layout' which is an empty linear layout. You can customize this with your own custom layout)`,
});

result = await prompts.prompt({
type: "text",
name: "widgetFeatures",
message: `Enable responsive layout features for this widget? (Default is 'Y')`,
});

const widgetFeatures = result.widgetFeatures || "Y";

const initialLayout = result.initialLayout || "ns_remote_views_linear_layout";
Comment on lines +1062 to +1076

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Read initialLayout before reusing result.

Line 1076 runs after result has been overwritten by the widget-features prompt, so a custom layout is always discarded and the generator always falls back to ns_remote_views_linear_layout.

Suggested fix
 result = await prompts.prompt({
 	type: "text",
 	name: "initialLayout",
 	message: `What initial layout would you like for this widget? (Default is 'ns_remote_views_linear_layout' which is an empty linear layout. You can customize this with your own custom layout)`,
 });
 
+const initialLayout = result.initialLayout || "ns_remote_views_linear_layout";
+
 result = await prompts.prompt({
 	type: "text",
 	name: "widgetFeatures",
 	message: `Enable responsive layout features for this widget? (Default is 'Y')`,
 });
 
 const widgetFeatures = result.widgetFeatures || "Y";
-
-const initialLayout = result.initialLayout || "ns_remote_views_linear_layout";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
result = await prompts.prompt({
type: "text",
name: "initialLayout",
message: `What initial layout would you like for this widget? (Default is 'ns_remote_views_linear_layout' which is an empty linear layout. You can customize this with your own custom layout)`,
});
result = await prompts.prompt({
type: "text",
name: "widgetFeatures",
message: `Enable responsive layout features for this widget? (Default is 'Y')`,
});
const widgetFeatures = result.widgetFeatures || "Y";
const initialLayout = result.initialLayout || "ns_remote_views_linear_layout";
result = await prompts.prompt({
type: "text",
name: "initialLayout",
message: `What initial layout would you like for this widget? (Default is 'ns_remote_views_linear_layout' which is an empty linear layout. You can customize this with your own custom layout)`,
});
const initialLayout = result.initialLayout || "ns_remote_views_linear_layout";
result = await prompts.prompt({
type: "text",
name: "widgetFeatures",
message: `Enable responsive layout features for this widget? (Default is 'Y')`,
});
const widgetFeatures = result.widgetFeatures || "Y";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/commands/widget.ts` around lines 1062 - 1076, The `widget.ts` prompt flow
overwrites `result` before `initialLayout` is read, so the earlier layout answer
is lost and the code always falls back to the default. Fix the `prompts.prompt`
sequence in the widget generator by capturing the `initialLayout` value
immediately after the first prompt (before the second `widgetFeatures` prompt
reassigns `result`), and keep the `widgetFeatures` handling separate so both
values are preserved.


const bundleId = this.$projectConfigService.getValue(`id`, "");

result = await prompts.prompt({
type: "text",
name: "widgetPackageName",
message: `What package name would you like to use for this widget? (Default is ${bundleId})`,
});

const widgetPackageName = result.widgetPackageName || bundleId;

result = await prompts.prompt({
type: "text",
name: "widgetClassName",
message: `What class name would you like to use for this widget? (Default is ${this.toAndroidClassName(name)}WidgetProvider)`,
});

const widgetClassName =
result.widgetClassName ||
`${this.toAndroidClassName(rawName)}WidgetProvider`;

await this.generateWidgetDescriptionResource(name, description);

await this.generateWidgetInfo(
name,
resizeMode,
minWidth,
minHeight,
initialLayout,
widgetFeatures === "N" ? false : true,
);

await this.generateWidget(
widgetPackageName,
widgetClassName,
updateInterval,
);

await this.generateAndroidManifest(
name,
widgetPackageName,
widgetClassName,
);
}

private async generateWidgetDescriptionResource(
name: string,
description: string,
) {
const appResourcePath = this.$projectData.appResourcesDirectoryPath;
const widgetsStringsInfoPath = path.join(
appResourcePath,
"Android",
"src",
"main",
"res",
"values",
`ns_widgets_strings_info.xml`,
);

if (!fs.existsSync(widgetsStringsInfoPath)) {
fs.mkdirSync(path.dirname(widgetsStringsInfoPath), { recursive: true });

const content = `<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="${name}_widget_description">${description}</string>
</resources>${EOL}`;

fs.writeFileSync(widgetsStringsInfoPath, content);
Comment on lines +1140 to +1145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate and escape prompt-derived values before writing files.

description, widgetPackageName, and widgetClassName are interpolated raw into XML, Kotlin, and path segments. Inputs like Weather & Clock, com.example-widget, or foo/bar will generate broken resources/source, and slash-containing names can escape the intended main/java subtree.

Also applies to: 1206-1224, 1260-1264

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 1144-1144: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.writeFileSync(widgetsStringsInfoPath, content)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(detect-non-literal-fs-filename-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/commands/widget.ts` around lines 1140 - 1145, The widget generation flow
is interpolating prompt-derived values directly into XML, Kotlin, and filesystem
paths, which can break output or allow path traversal. Update the logic around
fs.writeFileSync, widgetsStringsInfoPath, and the code that builds widget
source/package paths to validate and sanitize description, widgetPackageName,
and widgetClassName before use. Escape XML content for strings resources,
normalize package/class names to valid Kotlin identifiers, and reject or strip
path separators so generated files stay within the intended main/java subtree.

Source: Linters/SAST tools

} else {
const content = fs.readFileSync(widgetsStringsInfoPath).toString();
if (content.indexOf(`${name}_widget_description`) === -1) {
const updatedContent = content.replace(
"</resources>",
` <string name="${name}_widget_description">${description}</string>\n</resources>`,
);
fs.writeFileSync(widgetsStringsInfoPath, updatedContent);
}
}
}

private async generateWidgetInfo(
name: string,
resizeMode: string,
minWidth: string,
minHeight: string,
initialLayout: string,
enableWidgetFeatures: boolean,
) {
const appResourcePath = this.$projectData.appResourcesDirectoryPath;
const widgetInfoPath = path.join(
appResourcePath,
"Android",
"src",
"main",
"res",
"xml",
`ns_${name}_widget_info.xml`,
);

if (!fs.existsSync(widgetInfoPath)) {
fs.mkdirSync(path.dirname(widgetInfoPath), { recursive: true });

const content = `<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider
xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/${name}_widget_description"
android:initialLayout="@layout/${initialLayout}"
android:minWidth="${minWidth}"
android:minHeight="${minHeight}"
android:resizeMode="${resizeMode}"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen"
${enableWidgetFeatures ? 'android:widgetFeatures="reconfigurable|configuration_optional"' : ""}>
<meta-data
android:name="org.nativescript.widgets.MANAGED_WIDGET"
android:value="true"/>
</appwidget-provider>${EOL}`;

fs.writeFileSync(widgetInfoPath, content);
}
}

private async generateWidget(
packageName: string,
widgetClassName: string,
updateInterval: number,
) {
const appResourcePath = this.$projectData.appResourcesDirectoryPath;
const widgetPath = path.join(
appResourcePath,
"Android",
"src",
"main",
"java",
packageName.replace(/\./g, "/"),
`${widgetClassName}.kt`,
);

if (!fs.existsSync(widgetPath)) {
fs.mkdirSync(path.dirname(widgetPath), { recursive: true });

const content = `package ${packageName}
import org.nativescript.widgets.AppWidgetProvider

class ${widgetClassName} : AppWidgetProvider() {
override val interval = ${updateInterval}L
}
${EOL}`;

fs.writeFileSync(widgetPath, content);
}
}

private async generateAndroidManifest(
name: string,
packageName: string,
widgetClassName: string,
) {
const appResourcePath = this.$projectData.appResourcesDirectoryPath;
const mainManifestPath = path.join(
appResourcePath,
"Android",
"src",
"main",
"AndroidManifest.xml",
);

if (!fs.existsSync(mainManifestPath)) {
throw new Error("Main AndroidManifest.xml not found");
}

let manifestContent = fs.readFileSync(mainManifestPath, "utf-8");

const widgetMarkerStart = `<!-- BEGIN NativeScript Widget: ${name} -->`;
const widgetMarkerEnd = `<!-- END NativeScript Widget: ${name} -->`;

// Check if widget already exists
if (manifestContent.includes(widgetMarkerStart)) {
console.log(`Widget ${name} already exists in manifest, skipping...`);
return;
}

const widgetXml = `
${widgetMarkerStart}
<receiver
android:name="${packageName}.${widgetClassName}"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/ns_${name}_widget_info" />
</receiver>
${widgetMarkerEnd}`;

// Insert before </application>
manifestContent = manifestContent.replace(
"</application>",
`${widgetXml}\n </application>`,
);
Comment on lines +1260 to +1281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file first
ast-grep outline lib/commands/widget.ts --view expanded || true

# Read the relevant section around the reported lines
sed -n '1200,1320p' lib/commands/widget.ts

# Search for boot/app update wiring and related permissions in the codebase
rg -n "RECEIVE_BOOT_COMPLETED|MY_PACKAGE_REPLACED|BOOT_COMPLETED|APPWIDGET_UPDATE|android:exported|appwidget.provider" lib . -g '!**/node_modules/**' -g '!**/dist/**' -g '!**/build/**'

Repository: NativeScript/nativescript-cli

Length of output: 4918


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any manifest permission insertion or package-replaced handling in the repo
rg -n "RECEIVE_BOOT_COMPLETED|MY_PACKAGE_REPLACED|BOOT_COMPLETED|ACTION_MY_PACKAGE_REPLACED|PACKAGE_REPLACED|onReceive\\(|AppWidgetProvider" lib . -g '!**/node_modules/**' -g '!**/dist/**' -g '!**/build/**'

# Read the widget info generation section for surrounding intent assumptions
sed -n '1150,1210p' lib/commands/widget.ts

Repository: NativeScript/nativescript-cli

Length of output: 2351


Add the missing reboot/update wiring in the generated manifest. BOOT_COMPLETED also needs android.permission.RECEIVE_BOOT_COMPLETED, and the receiver should handle android.intent.action.MY_PACKAGE_REPLACED for app upgrades. Without both, widgets can fail to recover after reboot or update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/commands/widget.ts` around lines 1260 - 1281, The generated manifest
wiring in widgetXml is incomplete: the receiver in widget.ts needs the boot and
upgrade paths fully declared. Update the manifest generation around the widget
receiver so it also adds the RECEIVE_BOOT_COMPLETED permission when
BOOT_COMPLETED is included, and add an intent-filter for
android.intent.action.MY_PACKAGE_REPLACED alongside the existing
APPWIDGET_UPDATE handling. Use the widgetXml/manifestContent replacement block
to locate and adjust the receiver registration.


fs.writeFileSync(mainManifestPath, manifestContent);
}
}
injector.registerCommand(["widget"], WidgetCommand);
injector.registerCommand(["widget|ios"], WidgetIOSCommand);
injector.registerCommand(["widget|android"], WidgetAndroidCommand);