@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.
- package/package.json +1 -1
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +3 -0
- package/runtime/app/shell/capabilities.manifest.ts +65 -3
- package/runtime/app/shell/custom-webview.android.ts +22 -2
- package/runtime/app/shell/handlers-android.ts +54 -9
- package/runtime/app/shell/handlers-extended.ts +82 -4
- package/runtime/app/shell/handlers-system.ts +48 -1
- package/runtime/app/shell/handlers-widget.ts +97 -0
- package/runtime/app/shell/notif-identity.ts +45 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +79 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +7 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +70 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +40 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +13 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +10 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +275 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +25 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +9 -0
- package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +18 -0
- package/runtime/references.d.ts +1 -0
- package/runtime/tests/notif-identity.test.ts +43 -0
- package/runtime-desktop/chrome/appwrap-browser-chrome.html +70 -5
- package/runtime-desktop/crates/webview-control/src/lib.rs +2 -1
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +22 -9
- package/runtime-desktop/src-tauri/src/browser_tab.rs +379 -20
- package/runtime-desktop/src-tauri/src/main.rs +191 -11
- package/src/cli.ts +74 -0
- package/src/config.ts +8 -1
- package/src/plugin/host.ts +5 -0
- 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>
|
package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml
ADDED
|
@@ -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>
|
package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml
ADDED
|
@@ -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>
|
package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift
ADDED
|
@@ -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>
|
package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json
ADDED
|
@@ -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
|
+
}
|
package/runtime/references.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
};
|