@livx.cc/appwrap 0.54.0 → 0.56.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 +5 -4
- package/runtime/app/main-page.ts +0 -2
- package/runtime/app/shell/capabilities.manifest.ts +27 -92
- package/runtime/app/shell/config.ts +4 -0
- package/runtime/app/shell/env.ts +8 -1
- package/runtime/app/shell/handlers.ts +2 -2
- package/runtime/tests/bridge-response-delivery.test.ts +10 -1
- package/runtime/tests/env-scheme.test.ts +50 -0
- package/scripts/stage-assets.mjs +1 -1
- package/src/cli.ts +424 -621
- package/src/config.ts +8 -1
- package/src/handlers.ts +1 -1
- package/src/packs.ts +174 -0
- package/src/testing.ts +173 -0
- package/templates/module-pack/README.md +44 -0
- package/templates/module-pack/handler.ts +9 -0
- package/templates/module-pack/manifest.ts +27 -0
- package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
- package/runtime/app/shell/billing-offer.ts +0 -54
- package/runtime/app/shell/handlers-billing.ts +0 -662
- package/runtime/app/shell/handlers-health.ts +0 -178
- package/runtime/app/shell/handlers-widget.ts +0 -97
- package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
- package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
- package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
- package/runtime/tests/billing-offer.test.ts +0 -61
- package/runtime-desktop/autotest/index.html +0 -77
- package/runtime-desktop/bridge-shim/build.sh +0 -9
- package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
- package/runtime-desktop/bridge-shim/shim.ts +0 -132
- package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
- package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
- package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
- package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
- package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
- package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
- package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
- package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
- package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
- package/runtime-desktop/src-tauri/Cargo.toml +0 -45
- package/runtime-desktop/src-tauri/build.rs +0 -3
- package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
- package/runtime-desktop/src-tauri/icons/icon.png +0 -0
- package/runtime-desktop/src-tauri/shell_config.json +0 -10
- package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
- package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
- package/runtime-desktop/src-tauri/src/main.rs +0 -1228
- package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
- package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
- package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
- package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
- package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
- package/runtime-desktop/src-tauri/src/server.rs +0 -156
- package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
- package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
- package/src/desktop.ts +0 -300
- package/src/plugin/host.ts +0 -242
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
-
<!-- Widget chrome colors. Split into values/ + values-night/ so the tile follows the host's theme
|
|
3
|
-
(iOS parity: containerBackground(.fill.tertiary) + .primary label adapt automatically; a
|
|
4
|
-
hardcoded white slab with #333 text stayed stark-white on a dark home screen). The widget host
|
|
5
|
-
inflates RemoteViews with its own configuration, so -night resolves without any provider code. -->
|
|
6
|
-
<resources>
|
|
7
|
-
<color name="appwrap_widget_bg">#F2FFFFFF</color>
|
|
8
|
-
<color name="appwrap_widget_title">#222222</color>
|
|
9
|
-
<color name="appwrap_widget_label">#333333</color>
|
|
10
|
-
</resources>
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
-
<resources>
|
|
3
|
-
<!-- One grid row: 4 equal columns, CONTENT-HEIGHT. The absence of a layout_weight is load-bearing
|
|
4
|
-
(see the layout's contract comment) — a weighted row splits the widget height and floats its
|
|
5
|
-
icons mid-band, leaving a dead gap on a tall widget. -->
|
|
6
|
-
<style name="AppwrapWidgetRow">
|
|
7
|
-
<item name="android:layout_width">match_parent</item>
|
|
8
|
-
<item name="android:layout_height">wrap_content</item>
|
|
9
|
-
<item name="android:orientation">horizontal</item>
|
|
10
|
-
<item name="android:weightSum">4</item>
|
|
11
|
-
<item name="android:layout_marginBottom">10dp</item>
|
|
12
|
-
</style>
|
|
13
|
-
<!-- One icon-grid tile: a centered icon over a single-line label, equal-weight in its row.
|
|
14
|
-
Height is wrap_content so the row measures to content, not to the widget's full height. -->
|
|
15
|
-
<style name="AppwrapWidgetTile">
|
|
16
|
-
<item name="android:layout_width">0dp</item>
|
|
17
|
-
<item name="android:layout_height">wrap_content</item>
|
|
18
|
-
<item name="android:layout_weight">1</item>
|
|
19
|
-
<item name="android:orientation">vertical</item>
|
|
20
|
-
<item name="android:gravity">center_horizontal</item>
|
|
21
|
-
<item name="android:padding">4dp</item>
|
|
22
|
-
</style>
|
|
23
|
-
<!-- The provider rounds the bitmap itself (RemoteViews ImageView can't clip via outline), so the
|
|
24
|
-
source is drawn crisp at full res and just scaled to fit here — no fitCenter squashing. -->
|
|
25
|
-
<!-- The icon carries a top margin + SYMMETRIC start/end margins so the wrap_content badge-container
|
|
26
|
-
FrameLayout measures LARGER than the 44dp icon. The badge (pinned top|end of that padded
|
|
27
|
-
container) lands in the end-margin region — visually BEYOND the icon's top-right corner (iOS
|
|
28
|
-
offset(x:6,y:-6) parity) — yet stays WITHIN the container bounds, so RemoteViews' default
|
|
29
|
-
child-clipping never cuts it. marginStart mirrors marginEnd so the icon stays HORIZONTALLY
|
|
30
|
-
CENTERED within the FrameLayout (and thus under its label) — an end-only margin shifts the
|
|
31
|
-
icon left, leaving an asymmetric right gap.
|
|
32
|
-
SIZE: 44dp + 14dp of margins = 58dp, + 8dp tile padding = 66dp per column. At 4 columns that
|
|
33
|
-
needs ~284dp of widget width, which is why appwrap_widget_info declares minResizeWidth 280dp
|
|
34
|
-
— shrinking below that would start clipping the icons. -->
|
|
35
|
-
<style name="AppwrapWidgetIcon">
|
|
36
|
-
<item name="android:layout_width">44dp</item>
|
|
37
|
-
<item name="android:layout_height">44dp</item>
|
|
38
|
-
<item name="android:layout_marginTop">7dp</item>
|
|
39
|
-
<item name="android:layout_marginStart">7dp</item>
|
|
40
|
-
<item name="android:layout_marginEnd">7dp</item>
|
|
41
|
-
<item name="android:scaleType">fitXY</item>
|
|
42
|
-
</style>
|
|
43
|
-
<!-- Unread-count bubble overlapping the icon's top-right corner (iOS-parity: small, circular for
|
|
44
|
-
single digits, red fill, white ring, bold white number). ~40% of the 44dp icon. -->
|
|
45
|
-
<style name="AppwrapWidgetBadge">
|
|
46
|
-
<item name="android:layout_width">wrap_content</item>
|
|
47
|
-
<item name="android:layout_height">17dp</item>
|
|
48
|
-
<item name="android:layout_gravity">top|end</item>
|
|
49
|
-
<item name="android:minWidth">17dp</item>
|
|
50
|
-
<item name="android:paddingLeft">4dp</item>
|
|
51
|
-
<item name="android:paddingRight">4dp</item>
|
|
52
|
-
<item name="android:background">@drawable/appwrap_badge_bg</item>
|
|
53
|
-
<item name="android:gravity">center</item>
|
|
54
|
-
<item name="android:textColor">#FFFFFF</item>
|
|
55
|
-
<item name="android:textStyle">bold</item>
|
|
56
|
-
<item name="android:textSize">10sp</item>
|
|
57
|
-
<item name="android:includeFontPadding">false</item>
|
|
58
|
-
</style>
|
|
59
|
-
<style name="AppwrapWidgetLabel">
|
|
60
|
-
<item name="android:layout_width">wrap_content</item>
|
|
61
|
-
<item name="android:layout_height">wrap_content</item>
|
|
62
|
-
<item name="android:textColor">@color/appwrap_widget_label</item>
|
|
63
|
-
<item name="android:textSize">11sp</item>
|
|
64
|
-
<item name="android:maxLines">1</item>
|
|
65
|
-
<item name="android:ellipsize">end</item>
|
|
66
|
-
</style>
|
|
67
|
-
</resources>
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
-
<!-- Dark-theme counterpart of values/appwrap_widget_colors.xml (same names, resolved by the host's
|
|
3
|
-
night configuration). Translucent near-black slab + light text, mirroring the light variant's
|
|
4
|
-
alpha so the tile reads as the same material on either theme. -->
|
|
5
|
-
<resources>
|
|
6
|
-
<color name="appwrap_widget_bg">#E61C1C1E</color>
|
|
7
|
-
<color name="appwrap_widget_title">#FFFFFF</color>
|
|
8
|
-
<color name="appwrap_widget_label">#E6FFFFFF</color>
|
|
9
|
-
</resources>
|
package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
minResizeWidth 280dp: the grid is 4 fixed-width columns (~66dp each — see AppwrapWidgetIcon),
|
|
5
|
-
so ~284dp is the narrowest width that renders without clipping the icons. minWidth stays 250dp
|
|
6
|
-
(it only gates where the widget may be PLACED); minResizeWidth is what bounds a user drag. -->
|
|
7
|
-
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
|
8
|
-
android:minWidth="250dp"
|
|
9
|
-
android:minHeight="110dp"
|
|
10
|
-
android:minResizeWidth="280dp"
|
|
11
|
-
android:targetCellWidth="4"
|
|
12
|
-
android:targetCellHeight="2"
|
|
13
|
-
android:updatePeriodMillis="0"
|
|
14
|
-
android:initialLayout="@layout/appwrap_widget"
|
|
15
|
-
android:resizeMode="horizontal|vertical"
|
|
16
|
-
android:widgetCategory="home_screen"
|
|
17
|
-
android:previewLayout="@layout/appwrap_widget" />
|
|
@@ -1,10 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
3
|
-
hasFreeTrialPhase,
|
|
4
|
-
introPhase,
|
|
5
|
-
recurringPhase,
|
|
6
|
-
selectSubscriptionOffer,
|
|
7
|
-
type OfferCandidate,
|
|
8
|
-
} from '../app/shell/billing-offer';
|
|
9
|
-
|
|
10
|
-
// The two offers a trial-ELIGIBLE user sees. Order is deliberately trial-LAST to prove index-0 is
|
|
11
|
-
// unsafe by contract (the old offers.get(0) bug picked the base plan and dropped the trial).
|
|
12
|
-
const basePlan: OfferCandidate = {
|
|
13
|
-
offerId: null,
|
|
14
|
-
phases: [{ priceAmountMicros: 4_990_000, billingPeriod: 'P1M' }],
|
|
15
|
-
};
|
|
16
|
-
const trialOffer: OfferCandidate = {
|
|
17
|
-
offerId: 'freetrial',
|
|
18
|
-
phases: [
|
|
19
|
-
{ priceAmountMicros: 0, billingPeriod: 'P1W' },
|
|
20
|
-
{ priceAmountMicros: 4_990_000, billingPeriod: 'P1M' },
|
|
21
|
-
],
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
describe('selectSubscriptionOffer', () => {
|
|
25
|
-
test('trial present (base first) → picks the trial offer', () => {
|
|
26
|
-
expect(selectSubscriptionOffer([basePlan, trialOffer])).toBe(trialOffer);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('trial present (trial first) → still picks the trial offer regardless of order', () => {
|
|
30
|
-
expect(selectSubscriptionOffer([trialOffer, basePlan])).toBe(trialOffer);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test('trial absent (ineligible user) → falls back to the base-plan offer', () => {
|
|
34
|
-
expect(selectSubscriptionOffer([basePlan])).toBe(basePlan);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test('trial absent, offerId not null → returns the first offer as last resort', () => {
|
|
38
|
-
const promoNoTrial: OfferCandidate = { offerId: 'promo', phases: [{ priceAmountMicros: 2_990_000 }] };
|
|
39
|
-
expect(selectSubscriptionOffer([promoNoTrial])).toBe(promoNoTrial);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('empty list → null', () => {
|
|
43
|
-
expect(selectSubscriptionOffer([])).toBeNull();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
// Falsifiability guard: the trial offer's FIRST phase is free — if the code silently used
|
|
47
|
-
// offers.get(0) again, this would fail because base-first order would pick basePlan.
|
|
48
|
-
test('the selected trial offer exposes a zero-price intro phase and the recurring price', () => {
|
|
49
|
-
const chosen = selectSubscriptionOffer([basePlan, trialOffer])!;
|
|
50
|
-
expect(hasFreeTrialPhase(chosen)).toBe(true);
|
|
51
|
-
expect(introPhase(chosen)?.priceAmountMicros).toBe(0);
|
|
52
|
-
expect(introPhase(chosen)?.billingPeriod).toBe('P1W');
|
|
53
|
-
expect(recurringPhase(chosen)?.priceAmountMicros).toBe(4_990_000);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('base plan has no intro phase; recurring == its only paid phase', () => {
|
|
57
|
-
expect(hasFreeTrialPhase(basePlan)).toBe(false);
|
|
58
|
-
expect(introPhase(basePlan)).toBeUndefined();
|
|
59
|
-
expect(recurringPhase(basePlan)?.priceAmountMicros).toBe(4_990_000);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<meta charset="utf-8">
|
|
3
|
-
<title>appwrap desktop autotest</title>
|
|
4
|
-
<body style="font-family:monospace;background:#111;color:#0f0"><pre id="log">autotest</pre>
|
|
5
|
-
<canvas id="cv" width="800" height="600"></canvas>
|
|
6
|
-
<script>
|
|
7
|
-
const logEl = document.getElementById('log');
|
|
8
|
-
const say = (m) => { logEl.textContent += '\n' + m; };
|
|
9
|
-
// Phase markers travel over the real bridge so they interleave with [bridge] eprintln
|
|
10
|
-
// lines next to the watchdog's memory samples. UNSUPPORTED responses are irrelevant.
|
|
11
|
-
const mark = (name) => window.appwrapNative?.postMessage(JSON.stringify({ v: 1, id: 'ph-' + name, kind: 'request', method: 'phase.' + name, params: null }));
|
|
12
|
-
window.__appwrapDeliver = () => {}; // swallow responses
|
|
13
|
-
|
|
14
|
-
const cleanups = [];
|
|
15
|
-
const phases = [
|
|
16
|
-
['idle', () => {}],
|
|
17
|
-
['motion-listeners', () => {
|
|
18
|
-
const h1 = () => {}; const h2 = () => {};
|
|
19
|
-
window.addEventListener('devicemotion', h1);
|
|
20
|
-
window.addEventListener('deviceorientation', h2);
|
|
21
|
-
cleanups.push(() => { window.removeEventListener('devicemotion', h1); window.removeEventListener('deviceorientation', h2); });
|
|
22
|
-
}],
|
|
23
|
-
['raf-canvas', () => {
|
|
24
|
-
const ctx = document.getElementById('cv').getContext('2d');
|
|
25
|
-
let run = true, t = 0;
|
|
26
|
-
const loop = () => { if (!run) return; t++; ctx.fillStyle = `hsl(${t % 360},80%,50%)`; ctx.fillRect(0, 0, 800, 600); ctx.getImageData(0, 0, 100, 100); requestAnimationFrame(loop); };
|
|
27
|
-
loop();
|
|
28
|
-
cleanups.push(() => { run = false; });
|
|
29
|
-
}],
|
|
30
|
-
['speech-tts', () => {
|
|
31
|
-
let run = true;
|
|
32
|
-
const speak = () => { if (!run) return; const u = new SpeechSynthesisUtterance('test'); u.volume = 0; u.onend = speak; speechSynthesis.speak(u); };
|
|
33
|
-
speak();
|
|
34
|
-
cleanups.push(() => { run = false; speechSynthesis.cancel(); });
|
|
35
|
-
}],
|
|
36
|
-
['geo-watch', () => {
|
|
37
|
-
if (!navigator.geolocation) return say('no geolocation');
|
|
38
|
-
const id = navigator.geolocation.watchPosition(() => {}, () => {}, { enableHighAccuracy: true });
|
|
39
|
-
cleanups.push(() => navigator.geolocation.clearWatch(id));
|
|
40
|
-
}],
|
|
41
|
-
['bridge-storage-spam', () => {
|
|
42
|
-
let run = true, i = 0;
|
|
43
|
-
const big = 'x'.repeat(100_000);
|
|
44
|
-
const tick = () => {
|
|
45
|
-
if (!run) return;
|
|
46
|
-
for (let j = 0; j < 20; j++) window.appwrapNative.postMessage(JSON.stringify({ v: 1, id: 's' + (++i), kind: 'request', method: 'storage.set', params: { key: 'k' + (i % 50), value: big } }));
|
|
47
|
-
setTimeout(tick, 100);
|
|
48
|
-
};
|
|
49
|
-
tick();
|
|
50
|
-
cleanups.push(() => { run = false; });
|
|
51
|
-
}],
|
|
52
|
-
['toast-spam', () => {
|
|
53
|
-
let run = true, i = 0;
|
|
54
|
-
const tick = () => { if (!run || i > 40) return; i++; window.appwrapNative.postMessage(JSON.stringify({ v: 1, id: 't' + i, kind: 'request', method: 'toast.show', params: { message: 'spam ' + i } })); setTimeout(tick, 500); };
|
|
55
|
-
tick();
|
|
56
|
-
cleanups.push(() => { run = false; });
|
|
57
|
-
}],
|
|
58
|
-
['getusermedia', () => {
|
|
59
|
-
navigator.mediaDevices?.getUserMedia({ video: true, audio: true })
|
|
60
|
-
.then((s) => { say('gum ok'); cleanups.push(() => s.getTracks().forEach((t) => t.stop())); })
|
|
61
|
-
.catch((e) => say('gum err ' + e.name));
|
|
62
|
-
}],
|
|
63
|
-
['idle-end', () => {}],
|
|
64
|
-
];
|
|
65
|
-
|
|
66
|
-
let idx = 0;
|
|
67
|
-
const next = () => {
|
|
68
|
-
while (cleanups.length) { try { cleanups.pop()(); } catch {} }
|
|
69
|
-
if (idx >= phases.length) { mark('DONE'); say('DONE'); return; }
|
|
70
|
-
const [name, fn] = phases[idx++];
|
|
71
|
-
mark(name); say('phase: ' + name);
|
|
72
|
-
try { fn(); } catch (e) { say('threw: ' + e.message); }
|
|
73
|
-
setTimeout(next, 30_000);
|
|
74
|
-
};
|
|
75
|
-
next();
|
|
76
|
-
</script>
|
|
77
|
-
</body>
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
# Build the injectable content bundle. Must run with the bod-browser repo as cwd so its
|
|
3
|
-
# tsconfig `paths` (shared/*) resolve — the bundle reuses extension/src/content/* unmodified.
|
|
4
|
-
set -e
|
|
5
|
-
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
6
|
-
BOD_BROWSER="${BOD_BROWSER:-$HERE/../../../bod-browser}"
|
|
7
|
-
cd "$BOD_BROWSER"
|
|
8
|
-
bun build "$HERE/content-entry.ts" --outfile "$HERE/dist/content.js"
|
|
9
|
-
echo "built $HERE/dist/content.js"
|