@livx.cc/appwrap 0.47.3 → 0.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +3 -0
  3. package/runtime/app/shell/capabilities.manifest.ts +65 -3
  4. package/runtime/app/shell/custom-webview.android.ts +9 -0
  5. package/runtime/app/shell/handlers-android.ts +54 -9
  6. package/runtime/app/shell/handlers-extended.ts +82 -4
  7. package/runtime/app/shell/handlers-system.ts +48 -1
  8. package/runtime/app/shell/handlers-widget.ts +97 -0
  9. package/runtime/app/shell/notif-identity.ts +45 -0
  10. package/runtime/app/shell/web-quirks.ts +18 -0
  11. package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +79 -0
  12. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +7 -0
  13. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +70 -0
  14. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +40 -0
  15. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +13 -0
  16. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +10 -0
  17. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +275 -0
  18. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +25 -0
  19. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +9 -0
  20. package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +18 -0
  21. package/runtime/references.d.ts +1 -0
  22. package/runtime/tests/notif-identity.test.ts +43 -0
  23. package/runtime-desktop/chrome/appwrap-browser-chrome.html +70 -5
  24. package/runtime-desktop/crates/webview-control/src/lib.rs +2 -1
  25. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +22 -9
  26. package/runtime-desktop/src-tauri/src/browser_tab.rs +379 -20
  27. package/runtime-desktop/src-tauri/src/main.rs +191 -11
  28. package/src/cli.ts +74 -0
  29. package/src/config.ts +8 -1
  30. package/src/plugin/host.ts +5 -0
  31. package/src/plugin/types.ts +1 -0
@@ -0,0 +1,79 @@
1
+ package cc.livx.appwrap
2
+
3
+ import android.app.PendingIntent
4
+ import android.appwidget.AppWidgetManager
5
+ import android.appwidget.AppWidgetProvider
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.graphics.BitmapFactory
9
+ import android.net.Uri
10
+ import android.view.View
11
+ import android.widget.RemoteViews
12
+ import org.json.JSONObject
13
+ import java.io.File
14
+
15
+ /**
16
+ * Home-screen launcher widget (Android parity of the iOS WidgetKit launcher). Reads the payload the
17
+ * `widget.publish` handler wrote into SharedPreferences ("appwrap.widget" / key "appwrap.widget") and
18
+ * renders up to 4 icon-grid tiles; each tile deep-links back into the app via its entry's URL. Icons
19
+ * are PNGs the handler saved into the app files dir. Resource ids are looked up by name so the module
20
+ * needs no compile-time reference to the host app's R class.
21
+ */
22
+ class AppwrapWidgetProvider : AppWidgetProvider() {
23
+ private val tiles = listOf("tile0", "tile1", "tile2", "tile3")
24
+
25
+ override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) {
26
+ for (id in ids) render(ctx, mgr, id)
27
+ }
28
+
29
+ private fun res(ctx: Context, type: String, name: String) =
30
+ ctx.resources.getIdentifier(name, type, ctx.packageName)
31
+
32
+ private fun render(ctx: Context, mgr: AppWidgetManager, id: Int) {
33
+ val rv = RemoteViews(ctx.packageName, res(ctx, "layout", "appwrap_widget"))
34
+ val raw = ctx.getSharedPreferences("appwrap.widget", Context.MODE_PRIVATE)
35
+ .getString("appwrap.widget", null)
36
+ val payload = raw?.let { runCatching { JSONObject(it) }.getOrNull() }
37
+ val title = payload?.optString("title") ?: ""
38
+ rv.setTextViewText(res(ctx, "id", "appwrap_widget_title"), title)
39
+ rv.setViewVisibility(res(ctx, "id", "appwrap_widget_title"),
40
+ if (title.isEmpty()) View.GONE else View.VISIBLE)
41
+ val entries = payload?.optJSONArray("entries")
42
+
43
+ for (i in tiles.indices) {
44
+ val tileId = res(ctx, "id", tiles[i])
45
+ val e = if (entries != null && i < entries.length()) entries.optJSONObject(i) else null
46
+ if (e == null) { rv.setViewVisibility(tileId, View.INVISIBLE); continue }
47
+ rv.setViewVisibility(tileId, View.VISIBLE)
48
+ rv.setTextViewText(res(ctx, "id", "${tiles[i]}_label"), e.optString("label"))
49
+ // unread-count badge on the icon's top-right corner (absent/0 → hidden)
50
+ val badgeId = res(ctx, "id", "${tiles[i]}_badge")
51
+ val badge = e.optInt("badge", 0)
52
+ if (badge > 0) {
53
+ rv.setTextViewText(badgeId, if (badge > 99) "99+" else badge.toString())
54
+ rv.setViewVisibility(badgeId, View.VISIBLE)
55
+ } else rv.setViewVisibility(badgeId, View.GONE)
56
+ val icon = e.optString("icon", "")
57
+ if (icon.isNotEmpty()) {
58
+ val f = File(ctx.filesDir, icon)
59
+ if (f.exists()) BitmapFactory.decodeFile(f.absolutePath)
60
+ ?.let { rv.setImageViewBitmap(res(ctx, "id", "${tiles[i]}_icon"), it) }
61
+ }
62
+ val link = e.optString("deepLink", "")
63
+ if (link.isNotEmpty()) {
64
+ // Target the app's OWN launch activity explicitly + carry the deep link as data — the
65
+ // shell reads intent.getData() (like a pinned shortcut). Relying on an ACTION_VIEW
66
+ // intent-filter match would need an App Links autoVerify filter for https hosts, which
67
+ // the shell doesn't declare; an explicit component always opens the app.
68
+ val intent = (ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
69
+ ?: Intent(Intent.ACTION_VIEW).setPackage(ctx.packageName))
70
+ .setAction(Intent.ACTION_VIEW)
71
+ .setData(Uri.parse(link))
72
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
73
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
74
+ rv.setOnClickPendingIntent(tileId, PendingIntent.getActivity(ctx, i, intent, flags))
75
+ }
76
+ }
77
+ mgr.updateAppWidget(id, rv)
78
+ }
79
+ }
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Red pill background for the widget tile's unread-count badge. -->
3
+ <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
4
+ <solid android:color="#FF3B30" />
5
+ <stroke android:width="1.5dp" android:color="#FFFFFF" />
6
+ <corners android:radius="9dp" />
7
+ </shape>
@@ -0,0 +1,70 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- AppWrap launcher widget: title + a 2×2 icon-grid of deep-linked tiles. Populated by
3
+ AppwrapWidgetProvider from the published payload; empty tiles are set INVISIBLE. Each icon can
4
+ carry an unread-count badge (tileN_badge) overlaid on its top-right corner. -->
5
+ <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
6
+ android:layout_width="match_parent"
7
+ android:layout_height="match_parent"
8
+ android:orientation="vertical"
9
+ android:background="#F2FFFFFF"
10
+ android:padding="10dp">
11
+
12
+ <TextView
13
+ android:id="@+id/appwrap_widget_title"
14
+ android:layout_width="wrap_content"
15
+ android:layout_height="wrap_content"
16
+ android:textColor="#222222"
17
+ android:textStyle="bold"
18
+ android:textSize="12sp"
19
+ android:layout_marginBottom="6dp"
20
+ android:maxLines="1"
21
+ android:ellipsize="end" />
22
+
23
+ <LinearLayout
24
+ android:layout_width="match_parent"
25
+ android:layout_height="0dp"
26
+ android:layout_weight="1"
27
+ android:orientation="horizontal"
28
+ android:weightSum="2">
29
+
30
+ <LinearLayout android:id="@+id/tile0" style="@style/AppwrapWidgetTile">
31
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
32
+ <ImageView android:id="@+id/tile0_icon" style="@style/AppwrapWidgetIcon" />
33
+ <TextView android:id="@+id/tile0_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
34
+ </FrameLayout>
35
+ <TextView android:id="@+id/tile0_label" style="@style/AppwrapWidgetLabel" />
36
+ </LinearLayout>
37
+
38
+ <LinearLayout android:id="@+id/tile1" style="@style/AppwrapWidgetTile">
39
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
40
+ <ImageView android:id="@+id/tile1_icon" style="@style/AppwrapWidgetIcon" />
41
+ <TextView android:id="@+id/tile1_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
42
+ </FrameLayout>
43
+ <TextView android:id="@+id/tile1_label" style="@style/AppwrapWidgetLabel" />
44
+ </LinearLayout>
45
+ </LinearLayout>
46
+
47
+ <LinearLayout
48
+ android:layout_width="match_parent"
49
+ android:layout_height="0dp"
50
+ android:layout_weight="1"
51
+ android:orientation="horizontal"
52
+ android:weightSum="2">
53
+
54
+ <LinearLayout android:id="@+id/tile2" style="@style/AppwrapWidgetTile">
55
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
56
+ <ImageView android:id="@+id/tile2_icon" style="@style/AppwrapWidgetIcon" />
57
+ <TextView android:id="@+id/tile2_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
58
+ </FrameLayout>
59
+ <TextView android:id="@+id/tile2_label" style="@style/AppwrapWidgetLabel" />
60
+ </LinearLayout>
61
+
62
+ <LinearLayout android:id="@+id/tile3" style="@style/AppwrapWidgetTile">
63
+ <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
64
+ <ImageView android:id="@+id/tile3_icon" style="@style/AppwrapWidgetIcon" />
65
+ <TextView android:id="@+id/tile3_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
66
+ </FrameLayout>
67
+ <TextView android:id="@+id/tile3_label" style="@style/AppwrapWidgetLabel" />
68
+ </LinearLayout>
69
+ </LinearLayout>
70
+ </LinearLayout>
@@ -0,0 +1,40 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <!-- One icon-grid tile: a centered icon over a single-line label, equal-weight in its row. -->
4
+ <style name="AppwrapWidgetTile">
5
+ <item name="android:layout_width">0dp</item>
6
+ <item name="android:layout_height">match_parent</item>
7
+ <item name="android:layout_weight">1</item>
8
+ <item name="android:orientation">vertical</item>
9
+ <item name="android:gravity">center</item>
10
+ <item name="android:padding">4dp</item>
11
+ </style>
12
+ <style name="AppwrapWidgetIcon">
13
+ <item name="android:layout_width">36dp</item>
14
+ <item name="android:layout_height">36dp</item>
15
+ <item name="android:scaleType">fitCenter</item>
16
+ </style>
17
+ <!-- Unread-count bubble overlaid on the icon's top-right corner. -->
18
+ <style name="AppwrapWidgetBadge">
19
+ <item name="android:layout_width">wrap_content</item>
20
+ <item name="android:layout_height">18dp</item>
21
+ <item name="android:layout_gravity">top|end</item>
22
+ <item name="android:minWidth">18dp</item>
23
+ <item name="android:paddingLeft">5dp</item>
24
+ <item name="android:paddingRight">5dp</item>
25
+ <item name="android:background">@drawable/appwrap_badge_bg</item>
26
+ <item name="android:gravity">center</item>
27
+ <item name="android:textColor">#FFFFFF</item>
28
+ <item name="android:textStyle">bold</item>
29
+ <item name="android:textSize">10sp</item>
30
+ <item name="android:includeFontPadding">false</item>
31
+ </style>
32
+ <style name="AppwrapWidgetLabel">
33
+ <item name="android:layout_width">wrap_content</item>
34
+ <item name="android:layout_height">wrap_content</item>
35
+ <item name="android:textColor">#333333</item>
36
+ <item name="android:textSize">11sp</item>
37
+ <item name="android:maxLines">1</item>
38
+ <item name="android:ellipsize">end</item>
39
+ </style>
40
+ </resources>
@@ -0,0 +1,13 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Launcher widget: ~4×2 cells (medium), resizable, initial layout = the icon-grid. updatePeriodMillis
3
+ 0 → the app drives refreshes itself via widget.publish (AppWidgetManager broadcast). -->
4
+ <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
5
+ android:minWidth="250dp"
6
+ android:minHeight="110dp"
7
+ android:targetCellWidth="4"
8
+ android:targetCellHeight="2"
9
+ android:updatePeriodMillis="0"
10
+ android:initialLayout="@layout/appwrap_widget"
11
+ android:resizeMode="horizontal|vertical"
12
+ android:widgetCategory="home_screen"
13
+ android:previewLayout="@layout/appwrap_widget" />
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.security.application-groups</key>
6
+ <array>
7
+ <string>__APP_GROUP__</string>
8
+ </array>
9
+ </dict>
10
+ </plist>
@@ -0,0 +1,275 @@
1
+ import WidgetKit
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ // ── AppWrap home-screen widget ──────────────────────────────────────────────
6
+ // Data-driven: the app publishes a small JSON payload into the App Group container (via the
7
+ // `widget.publish` native handler); this extension reads it at render and draws one of three
8
+ // templates. Each tile/row carries its own deep link (a full URL the app's deep-link routing maps to
9
+ // a screen). No app-specific values are hardcoded — the App Group id is templated in by the CLI.
10
+
11
+ private let appGroup = "__APP_GROUP__" // stamped by the CLI → group.<appId>
12
+ private let launcherKey = "appwrap.widget" // launcher (icon-grid) payload key
13
+ private let dataKey = "appwrap.widget.data" // data widget (stat/list) payload key
14
+
15
+ // ── Payload model (mirrors the widget.publish contract) ──
16
+ struct WidgetEntry: Decodable {
17
+ let label: String
18
+ var sublabel: String?
19
+ var deepLink: String?
20
+ var icon: String? // filename of a PNG the handler saved into the App Group container
21
+ var badge: Int? // unread/attention count drawn as a bubble on the tile (absent/0 = none)
22
+ }
23
+ struct WidgetStat: Decodable {
24
+ let value: String
25
+ var label: String?
26
+ var sublabel: String?
27
+ }
28
+ struct WidgetPayload: Decodable {
29
+ let template: String // "icon-grid" | "stat" | "list"
30
+ var title: String?
31
+ var entries: [WidgetEntry]?
32
+ var stat: WidgetStat?
33
+ var deepLink: String? // whole-widget link (stat template / fallback)
34
+ }
35
+
36
+ private func loadPayload(_ key: String) -> WidgetPayload? {
37
+ guard let raw = UserDefaults(suiteName: appGroup)?.string(forKey: key),
38
+ let data = raw.data(using: .utf8) else { return nil }
39
+ return try? JSONDecoder().decode(WidgetPayload.self, from: data)
40
+ }
41
+
42
+ private func containerImage(_ name: String?) -> UIImage? {
43
+ guard let name, !name.isEmpty,
44
+ let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
45
+ else { return nil }
46
+ return UIImage(contentsOfFile: dir.appendingPathComponent(name).path)
47
+ }
48
+
49
+ // ── Timeline ──
50
+ struct Entry: TimelineEntry {
51
+ let date: Date
52
+ let payload: WidgetPayload?
53
+ }
54
+ struct Provider: TimelineProvider {
55
+ let key: String
56
+ func placeholder(in context: Context) -> Entry { Entry(date: Date(), payload: nil) }
57
+ func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) {
58
+ completion(Entry(date: Date(), payload: loadPayload(key)))
59
+ }
60
+ func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
61
+ completion(Timeline(entries: [Entry(date: Date(), payload: loadPayload(key))], policy: .never))
62
+ }
63
+ }
64
+
65
+ // ── Tile / row views ──
66
+ private struct TileView: View {
67
+ @Environment(\.widgetFamily) private var family
68
+ let entry: WidgetEntry
69
+ private var iconSize: CGFloat { family == .systemLarge ? 60 : 52 }
70
+
71
+ var body: some View {
72
+ let inner = VStack(spacing: 6) {
73
+ icon
74
+ Text(entry.label)
75
+ .font(.system(size: 12, weight: .medium))
76
+ .foregroundStyle(.primary)
77
+ .lineLimit(1).minimumScaleFactor(0.8)
78
+ }
79
+ .frame(maxWidth: .infinity)
80
+ .contentShape(Rectangle()) // whole tile is the hit target — ambiguous taps otherwise route wrong
81
+ if let link = entry.deepLink, let url = URL(string: link) {
82
+ Link(destination: url) { inner }.tint(.primary)
83
+ } else { inner }
84
+ }
85
+
86
+ @ViewBuilder private var icon: some View {
87
+ let shape = RoundedRectangle(cornerRadius: iconSize * 0.25, style: .continuous)
88
+ Group {
89
+ if let img = containerImage(entry.icon) {
90
+ Image(uiImage: img).resizable().scaledToFill()
91
+ } else {
92
+ shape.fill(LinearGradient(colors: [Color.gray.opacity(0.55), Color.gray.opacity(0.35)],
93
+ startPoint: .top, endPoint: .bottom))
94
+ .overlay(Text(String(entry.label.prefix(1)).uppercased())
95
+ .font(.system(size: 22, weight: .semibold)).foregroundStyle(.white))
96
+ }
97
+ }
98
+ .frame(width: iconSize, height: iconSize)
99
+ .clipShape(shape)
100
+ .overlay(shape.strokeBorder(.white.opacity(0.14), lineWidth: 0.5))
101
+ .shadow(color: .black.opacity(0.18), radius: 3, x: 0, y: 1)
102
+ .overlay(alignment: .topTrailing) { badge } // iOS-style unread bubble, overhangs the icon corner
103
+ }
104
+
105
+ @ViewBuilder private var badge: some View {
106
+ if let n = entry.badge, n > 0 {
107
+ Text(n > 99 ? "99+" : "\(n)")
108
+ .font(.system(size: 11, weight: .bold))
109
+ .foregroundStyle(.white)
110
+ .padding(.horizontal, 5).frame(minWidth: 18, minHeight: 18)
111
+ .background(Color.red, in: Capsule())
112
+ .overlay(Capsule().strokeBorder(.white, lineWidth: 1.5))
113
+ .offset(x: 6, y: -6)
114
+ }
115
+ }
116
+ }
117
+
118
+ private struct RowView: View {
119
+ let entry: WidgetEntry
120
+ var body: some View {
121
+ let inner = HStack(spacing: 10) {
122
+ if let img = containerImage(entry.icon) {
123
+ Image(uiImage: img).resizable().aspectRatio(contentMode: .fit).frame(width: 26, height: 26).clipShape(RoundedRectangle(cornerRadius: 6))
124
+ }
125
+ VStack(alignment: .leading, spacing: 1) {
126
+ Text(entry.label).font(.caption).bold().lineLimit(1)
127
+ if let s = entry.sublabel { Text(s).font(.caption2).foregroundStyle(.secondary).lineLimit(1) }
128
+ }
129
+ Spacer(minLength: 0)
130
+ }
131
+ .frame(maxWidth: .infinity, alignment: .leading)
132
+ if let link = entry.deepLink, let url = URL(string: link) {
133
+ Link(destination: url) { inner }
134
+ } else { inner }
135
+ }
136
+ }
137
+
138
+ // ── Root view — routes on template + family ──
139
+ struct AppwrapWidgetEntryView: View {
140
+ @Environment(\.widgetFamily) private var family
141
+ var entry: Provider.Entry
142
+
143
+ var body: some View {
144
+ content.containerBackgroundCompat()
145
+ }
146
+
147
+ @ViewBuilder private var content: some View {
148
+ if let p = entry.payload {
149
+ switch p.template {
150
+ case "stat": statView(p)
151
+ case "list": listView(p)
152
+ default: gridView(p) // "icon-grid"
153
+ }
154
+ } else {
155
+ emptyView
156
+ }
157
+ }
158
+
159
+ private var columns: Int { family == .systemSmall ? 2 : 4 }
160
+ private var maxTiles: Int { family == .systemLarge ? 12 : (family == .systemSmall ? 4 : 4) }
161
+ private var maxRows: Int { family == .systemLarge ? 6 : 3 }
162
+
163
+ @ViewBuilder private func header(_ p: WidgetPayload) -> some View {
164
+ HStack(spacing: 6) {
165
+ Text(p.title ?? "My Apps").font(.system(size: 15, weight: .semibold))
166
+ Spacer(minLength: 0)
167
+ // the grid glyph opens the app's "My Apps" drawer (payload.deepLink carries the menu link)
168
+ let glyph = Image(systemName: "square.grid.2x2.fill")
169
+ .font(.system(size: 13, weight: .semibold)).foregroundStyle(.secondary)
170
+ if let link = p.deepLink, let url = URL(string: link) {
171
+ Link(destination: url) { glyph.padding(6).contentShape(Rectangle()) }.tint(.secondary)
172
+ } else { glyph }
173
+ }
174
+ }
175
+
176
+ @ViewBuilder private func gridView(_ p: WidgetPayload) -> some View {
177
+ let items = Array((p.entries ?? []).prefix(maxTiles))
178
+ VStack(alignment: .leading, spacing: family == .systemSmall ? 10 : 14) {
179
+ if family != .systemSmall { header(p) }
180
+ if items.isEmpty {
181
+ Spacer()
182
+ Text("Pin apps in Blank to see them here")
183
+ .font(.footnote).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .center)
184
+ Spacer()
185
+ } else {
186
+ // Native Grid (iOS 16+, the extension's target): a short final row aligns into the
187
+ // leading columns automatically, rows are content-height (no greedy filler), and per-tile
188
+ // Links keep distinct hit targets (a LazyVGrid would route every tap to the first link).
189
+ let chunks = stride(from: 0, to: items.count, by: columns).map { Array(items[$0 ..< min($0 + columns, items.count)]) }
190
+ Grid(alignment: .top, horizontalSpacing: 8, verticalSpacing: family == .systemLarge ? 16 : 12) {
191
+ ForEach(chunks.indices, id: \.self) { r in
192
+ GridRow {
193
+ ForEach(chunks[r].indices, id: \.self) { c in TileView(entry: chunks[r][c]) }
194
+ }
195
+ }
196
+ }
197
+ Spacer(minLength: 0) // top-align the grid (app-folder feel), no floating gap
198
+ }
199
+ }
200
+ }
201
+
202
+ @ViewBuilder private func listView(_ p: WidgetPayload) -> some View {
203
+ let items = Array((p.entries ?? []).prefix(maxRows))
204
+ VStack(alignment: .leading, spacing: 8) {
205
+ if let t = p.title { Text(t).font(.caption).bold() }
206
+ ForEach(items.indices, id: \.self) { RowView(entry: items[$0]) }
207
+ Spacer(minLength: 0)
208
+ }
209
+ }
210
+
211
+ @ViewBuilder private func statView(_ p: WidgetPayload) -> some View {
212
+ let inner = VStack(alignment: .leading, spacing: 2) {
213
+ if let t = p.title { Text(t).font(.caption2).foregroundStyle(.secondary).lineLimit(1) }
214
+ Spacer(minLength: 0)
215
+ Text(p.stat?.value ?? "—").font(.system(size: 40, weight: .bold, design: .rounded)).minimumScaleFactor(0.5).lineLimit(1)
216
+ if let l = p.stat?.label { Text(l).font(.caption).bold().lineLimit(1) }
217
+ if let s = p.stat?.sublabel { Text(s).font(.caption2).foregroundStyle(.secondary).lineLimit(1) }
218
+ }
219
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
220
+ if let link = p.deepLink, let url = URL(string: link) { Link(destination: url) { inner } } else { inner }
221
+ }
222
+
223
+ private var emptyView: some View {
224
+ VStack(spacing: 6) {
225
+ Image(systemName: "square.grid.2x2.fill").font(.title)
226
+ Text("Open App").font(.caption).bold()
227
+ }
228
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
229
+ .widgetURL(entry.payload?.deepLink.flatMap { URL(string: $0) })
230
+ }
231
+ }
232
+
233
+ private extension View {
234
+ // iOS 17+ requires an explicit container background for Home Screen widgets; older builds pad+fill.
235
+ @ViewBuilder func containerBackgroundCompat() -> some View {
236
+ if #available(iOS 17.0, *) {
237
+ self.padding(12).containerBackground(.fill.tertiary, for: .widget)
238
+ } else {
239
+ self.padding(12).background(Color(white: 0.96))
240
+ }
241
+ }
242
+ }
243
+
244
+ // Two distinct kinds so a mini-app's data widget never clobbers the launcher (each reads its own key).
245
+ struct AppwrapLauncherWidget: Widget {
246
+ let kind = "AppwrapLauncher"
247
+ var body: some WidgetConfiguration {
248
+ StaticConfiguration(kind: kind, provider: Provider(key: launcherKey)) { entry in
249
+ AppwrapWidgetEntryView(entry: entry)
250
+ }
251
+ .configurationDisplayName("AppWrap")
252
+ .description("Your pinned apps.")
253
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
254
+ }
255
+ }
256
+
257
+ struct AppwrapDataWidget: Widget {
258
+ let kind = "AppwrapData"
259
+ var body: some WidgetConfiguration {
260
+ StaticConfiguration(kind: kind, provider: Provider(key: dataKey)) { entry in
261
+ AppwrapWidgetEntryView(entry: entry)
262
+ }
263
+ .configurationDisplayName("AppWrap Data")
264
+ .description("Live stats from an app.")
265
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
266
+ }
267
+ }
268
+
269
+ @main
270
+ struct AppwrapWidgetBundle: WidgetBundle {
271
+ var body: some Widget {
272
+ AppwrapLauncherWidget()
273
+ AppwrapDataWidget()
274
+ }
275
+ }
@@ -0,0 +1,25 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDisplayName</key>
6
+ <string>AppWrap</string>
7
+ <key>CFBundleName</key>
8
+ <string>AppwrapWidget</string>
9
+ <key>CFBundleIdentifier</key>
10
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11
+ <key>CFBundleExecutable</key>
12
+ <string>$(EXECUTABLE_NAME)</string>
13
+ <key>CFBundlePackageType</key>
14
+ <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
15
+ <key>CFBundleShortVersionString</key>
16
+ <string>1.0</string>
17
+ <key>CFBundleVersion</key>
18
+ <string>1</string>
19
+ <key>NSExtension</key>
20
+ <dict>
21
+ <key>NSExtensionPointIdentifier</key>
22
+ <string>com.apple.widgetkit-extension</string>
23
+ </dict>
24
+ </dict>
25
+ </plist>
@@ -0,0 +1,9 @@
1
+ {
2
+ "frameworks": ["WidgetKit", "SwiftUI"],
3
+ "targetBuildConfigurationProperties": {
4
+ "IPHONEOS_DEPLOYMENT_TARGET": "16.0",
5
+ "SWIFT_VERSION": "5.0",
6
+ "CODE_SIGN_ENTITLEMENTS": "../../App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements",
7
+ "INFOPLIST_FILE": "../../App_Resources/iOS/extensions/AppwrapWidget/Info.plist"
8
+ }
9
+ }
@@ -0,0 +1,18 @@
1
+ import Foundation
2
+ import WidgetKit
3
+
4
+ // WidgetKit's `WidgetCenter.shared.reloadAllTimelines()` is Swift-only — it has NO Objective-C API, so
5
+ // NativeScript's ObjC bridge can't call it directly (a JS `WidgetCenter.sharedCenter…` is a silent
6
+ // no-op). This @objc shim bridges it so the widget.publish handler can force a refresh after writing
7
+ // the App Group payload — otherwise a PLACED widget keeps its stale (empty) timeline (policy .never)
8
+ // while only the gallery preview re-snapshots live. Shipped via the widget module's nativeSrc →
9
+ // compiled into the app (and links WidgetKit into the main target) only when `widget` is active.
10
+ @objc(AppwrapWidgetReload)
11
+ public class AppwrapWidgetReload: NSObject {
12
+ /// Reload every timeline of every kind (launcher + data). iOS 14+.
13
+ @objc public static func reloadAll() {
14
+ if #available(iOS 14.0, *) {
15
+ WidgetCenter.shared.reloadAllTimelines()
16
+ }
17
+ }
18
+ }
@@ -23,3 +23,4 @@
23
23
  /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!Photos.d.ts" />
24
24
  /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!AppTrackingTransparency.d.ts" />
25
25
  /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!AdSupport.d.ts" />
26
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!Intents.d.ts" />
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { notifIdentity } from '../app/shell/notif-identity';
3
+
4
+ describe('notifIdentity', () => {
5
+ test('no sender/icon → plain, identity not used', () => {
6
+ const r = notifIdentity({ title: 'Reminder', body: 'Hi' });
7
+ expect(r).toEqual({
8
+ title: 'Reminder', subtitle: '', body: 'Hi',
9
+ senderName: '', iconUrl: '', useIdentity: false,
10
+ });
11
+ });
12
+
13
+ test('sender named → sender becomes title, original demoted to subtitle', () => {
14
+ const r = notifIdentity({ title: 'You won!', body: 'Tap', sender: 'Coin Flip', icon: 'https://x/i.png' });
15
+ expect(r.title).toBe('Coin Flip');
16
+ expect(r.subtitle).toBe('You won!');
17
+ expect(r.senderName).toBe('Coin Flip');
18
+ expect(r.iconUrl).toBe('https://x/i.png');
19
+ expect(r.useIdentity).toBe(true);
20
+ });
21
+
22
+ test('subtitle dropped when title duplicates the sender', () => {
23
+ const r = notifIdentity({ title: 'Coin Flip', sender: 'Coin Flip' });
24
+ expect(r.title).toBe('Coin Flip');
25
+ expect(r.subtitle).toBe('');
26
+ });
27
+
28
+ test('icon only (no sender) → identity used, title kept, no subtitle', () => {
29
+ const r = notifIdentity({ title: 'Ping', icon: 'data:image/png;base64,AAAA' });
30
+ expect(r.title).toBe('Ping');
31
+ expect(r.subtitle).toBe('');
32
+ expect(r.senderName).toBe('');
33
+ expect(r.iconUrl).toBe('data:image/png;base64,AAAA');
34
+ expect(r.useIdentity).toBe(true);
35
+ });
36
+
37
+ test('missing title/body coerce to empty strings', () => {
38
+ const r = notifIdentity({});
39
+ expect(r.title).toBe('');
40
+ expect(r.body).toBe('');
41
+ expect(r.useIdentity).toBe(false);
42
+ });
43
+ });