@livx.cc/appwrap 0.47.2 → 0.48.0

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 (30) 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 +22 -2
  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/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +79 -0
  11. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +7 -0
  12. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +70 -0
  13. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +40 -0
  14. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +13 -0
  15. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +10 -0
  16. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +275 -0
  17. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +25 -0
  18. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +9 -0
  19. package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +18 -0
  20. package/runtime/references.d.ts +1 -0
  21. package/runtime/tests/notif-identity.test.ts +43 -0
  22. package/runtime-desktop/chrome/appwrap-browser-chrome.html +70 -5
  23. package/runtime-desktop/crates/webview-control/src/lib.rs +2 -1
  24. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +22 -9
  25. package/runtime-desktop/src-tauri/src/browser_tab.rs +379 -20
  26. package/runtime-desktop/src-tauri/src/main.rs +191 -11
  27. package/src/cli.ts +74 -0
  28. package/src/config.ts +8 -1
  29. package/src/plugin/host.ts +5 -0
  30. package/src/plugin/types.ts +1 -0
@@ -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
+ });
@@ -40,11 +40,12 @@
40
40
  background: var(--field-bg); color: var(--fg); font-size: 13px; outline: none;
41
41
  }
42
42
  #addr:focus { border-color: #4c8bf5; }
43
- #profile {
43
+ #profile, #newprof {
44
44
  flex: none; height: 28px; max-width: 160px;
45
45
  border: 1px solid var(--field-border); border-radius: 7px;
46
46
  background: var(--field-bg); color: var(--fg); font-size: 12px; padding: 0 4px;
47
47
  }
48
+ #newprof:focus { border-color: #4c8bf5; outline: none; }
48
49
  </style>
49
50
  </head>
50
51
  <body>
@@ -55,10 +56,17 @@
55
56
  <input id="addr" type="text" spellcheck="false" autocomplete="off"
56
57
  placeholder="Enter address (e.g. 127.0.0.1:3000)" />
57
58
  <select id="profile" title="Profile"></select>
59
+ <input id="newprof" type="text" spellcheck="false" autocomplete="off"
60
+ placeholder="profile name — Enter" style="display:none" />
58
61
  </div>
59
62
  <script>
60
63
  (function () {
61
- var id = new URLSearchParams(location.search).get('id') || '';
64
+ // id + profile arrive via hash fragment (#id=…&profile=…) — a query (?…) breaks Tauri's App-asset
65
+ // resolution. Read hash first, fall back to search for robustness.
66
+ var hp = new URLSearchParams(location.hash.replace(/^#/, ''));
67
+ var sp = new URLSearchParams(location.search);
68
+ var id = hp.get('id') || sp.get('id') || '';
69
+ var initProfile = hp.get('profile') || sp.get('profile') || 'default';
62
70
  function invoke(cmd, args) {
63
71
  var core = window.__TAURI__ && window.__TAURI__.core;
64
72
  if (!core) return Promise.reject(new Error('Tauri core unavailable'));
@@ -85,17 +93,71 @@
85
93
  if (url) invoke('browser_tab_navigate', { id: id, url: url });
86
94
  });
87
95
 
96
+ var NEW_PROFILE = '__new__'; // sentinel option: reveal an inline input, then switch to the name
97
+ var current = initProfile; // the profile actually shown (to revert the <select> on cancel)
98
+ var newprof = document.getElementById('newprof');
99
+
100
+ function addProfileOption(name) {
101
+ if (!name) return;
102
+ for (var i = 0; i < profileSel.options.length; i++) {
103
+ if (profileSel.options[i].value === name) return; // already listed
104
+ }
105
+ var opt = document.createElement('option');
106
+ opt.value = name; opt.textContent = name;
107
+ profileSel.insertBefore(opt, profileSel.lastChild); // before the "+ New profile…" item
108
+ }
109
+
110
+ function switchProfile(name) {
111
+ // Switching recreates the webview with a fresh, isolated store; the name is persisted on first
112
+ // use (uuid_for) so it reappears in the list next launch.
113
+ addProfileOption(name);
114
+ profileSel.value = name;
115
+ current = name;
116
+ invoke('browser_subwindow_switch_profile', { id: id, profileId: name });
117
+ }
118
+
119
+ // Inline "+ New profile" flow — WKWebView has no window.prompt (no text-input panel delegate), so
120
+ // reveal a small input instead of prompting. Enter commits, Escape/empty-blur cancels.
121
+ function beginNewProfile() {
122
+ profileSel.style.display = 'none';
123
+ newprof.style.display = '';
124
+ newprof.value = '';
125
+ newprof.focus();
126
+ }
127
+ function endNewProfile(commit) {
128
+ if (newprof.style.display === 'none') return; // already closed (guard double-fire)
129
+ var name = (newprof.value || '').trim();
130
+ newprof.style.display = 'none';
131
+ profileSel.style.display = '';
132
+ profileSel.value = current; // reset the sentinel selection
133
+ if (commit && name && name !== current) switchProfile(name);
134
+ }
135
+ newprof.addEventListener('keydown', function (e) {
136
+ if (e.key === 'Enter') endNewProfile(true);
137
+ else if (e.key === 'Escape') endNewProfile(false);
138
+ });
139
+ newprof.addEventListener('blur', function () { endNewProfile(true); });
140
+
88
141
  profileSel.addEventListener('change', function () {
89
- invoke('browser_subwindow_switch_profile', { id: id, profileId: profileSel.value });
142
+ var val = profileSel.value;
143
+ if (val === NEW_PROFILE) { beginNewProfile(); return; }
144
+ current = val;
145
+ invoke('browser_subwindow_switch_profile', { id: id, profileId: val });
90
146
  });
91
147
 
92
- // Populate the profile dropdown.
148
+ // Populate the profile dropdown, then append the "+ New profile…" sentinel.
93
149
  invoke('browser_list_profiles').then(function (profiles) {
94
150
  (profiles || ['default']).forEach(function (p) {
95
151
  var opt = document.createElement('option');
96
152
  opt.value = p; opt.textContent = p;
97
153
  profileSel.appendChild(opt);
98
154
  });
155
+ var add = document.createElement('option');
156
+ add.value = NEW_PROFILE; add.textContent = '+ New profile…';
157
+ profileSel.appendChild(add);
158
+ // Reflect the window's ACTUAL mounted profile (may be non-default on a restored window).
159
+ addProfileOption(initProfile);
160
+ profileSel.value = initProfile;
99
161
  }).catch(function (e) { console.error('list profiles failed', e); });
100
162
 
101
163
  // Keep the address bar in sync with the controlled webview (skip while the user is editing it).
@@ -112,8 +174,11 @@
112
174
  } catch (_) {}
113
175
  }).catch(function () {});
114
176
  }
115
- setInterval(poll, 1000);
177
+ var pollTimer = setInterval(poll, 1000);
116
178
  poll();
179
+ // Stop polling as the window unloads — a poll's current_url eval racing the native teardown blocks
180
+ // the closing window (the webview is gone, so the eval never resolves). Fixes the close-hang.
181
+ window.addEventListener('pagehide', function () { clearInterval(pollTimer); });
117
182
  })();
118
183
  </script>
119
184
  </body>
@@ -90,7 +90,8 @@ pub enum Mount {
90
90
  #[cfg(target_os = "macos")]
91
91
  pub use wkwebview_backend::{
92
92
  close, create_profile_window, create_webview, devtools_enabled, eval_async, go_back, go_forward,
93
- inject_script, install_message_handler, list_webviews, navigate, profile_webview, reload,
93
+ inject_script, install_message_handler, install_named_message_handler, list_webviews, navigate,
94
+ profile_webview, reload,
94
95
  set_inspectable, set_mount, snapshot, user_close, CreateResult, CreateSpec, MessageSink,
95
96
  WebviewInfo,
96
97
  };