rvlib-shipkit 1.3.0 → 1.4.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/CHANGELOG.md +37 -0
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +4 -2
- package/src/assets/kits/identity-kit.ts +15 -13
- package/src/assets/run-assets.ts +12 -2
- package/src/check.ts +6 -4
- package/src/checks/SK001-package-json-shape.check.ts +20 -2
- package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
- package/src/checks/SK014-prose-ste.check.ts +16 -6
- package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
- package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
- package/src/checks/SK028-readme.check.ts +6 -2
- package/src/cli/cmd_CrossRepoActivity.ts +4 -1
- package/src/collections/collection-types.ts +15 -0
- package/src/collections/providers.ts +6 -2
- package/src/contracts/apply-contracts.ts +9 -8
- package/src/contracts/contract-types.ts +8 -2
- package/src/contracts/contracts-engine.ts +34 -13
- package/src/contracts/manifests/identity.contract.ts +6 -5
- package/src/contracts/manifests/social.contract.ts +4 -2
- package/src/contracts/manifests/steam.contract.ts +7 -3
- package/src/contracts/web-surface.ts +59 -0
- package/src/core/budget.ts +19 -9
- package/src/core/check-runner.ts +26 -5
- package/src/core/discover-checks.ts +8 -5
- package/src/core/layout.ts +2 -0
- package/src/core/motherhome.ts +88 -66
- package/src/core/run-check.ts +2 -5
- package/src/core/run-ci.ts +3 -3
- package/src/core/wire.ts +38 -9
- package/src/home/activity-shape.ts +4 -1
- package/src/home/activity.ts +22 -15
- package/src/home/gui-app/ShipkitGui.swift +144 -25
- package/src/home/gui-app.ts +93 -30
- package/src/home/home-launchd.ts +10 -2
- package/src/home/home-routes.ts +2 -1
- package/src/home/home-start.ts +33 -5
- package/src/home/machine-stores.ts +4 -2
- package/src/home/open-app.ts +23 -10
- package/src/home/pm-config.ts +15 -11
- package/src/home/pm-inputs.ts +76 -0
- package/src/home/pm.ts +3 -3
- package/src/home/process-rows.ts +23 -6
- package/src/home/resources.ts +67 -15
- package/src/home/run-daemon.ts +6 -1
- package/src/home/run-pm.ts +29 -5
- package/src/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +12 -2
- package/src/ledger/gate-checks.ts +3 -3
- package/src/ledger/store.ts +11 -0
- package/src/ledger/world-cache.ts +17 -15
- package/src/repo-config.ts +6 -1
- package/src/serve/hub-app/activity-view.tsx +3 -3
- package/src/serve/hub-app/checks-svc.ts +2 -1
- package/src/serve/hub-app/col-tables-view.tsx +124 -0
- package/src/serve/hub-app/col-tables.ts +115 -0
- package/src/serve/hub-app/cols-svc.ts +28 -0
- package/src/serve/hub-app/dock.css +1 -1
- package/src/serve/hub-app/home-st.ts +1 -1
- package/src/serve/hub-app/home-strip.css +32 -17
- package/src/serve/hub-app/home-strip.tsx +99 -89
- package/src/serve/hub-app/hub-app.tsx +7 -1
- package/src/serve/hub-app/hub-types.ts +6 -3
- package/src/serve/hub-app/hub.css +24 -2
- package/src/serve/hub-app/icons.tsx +4 -0
- package/src/serve/hub-app/keys.ts +6 -6
- package/src/serve/hub-app/machine-st.ts +26 -37
- package/src/serve/hub-app/palette-st.ts +5 -5
- package/src/serve/hub-app/palette-view.tsx +1 -1
- package/src/serve/hub-app/project-list.tsx +3 -3
- package/src/serve/hub-app/repo-st.ts +1 -1
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/timeline-view.tsx +14 -11
- package/src/serve/project-gutter.ts +1 -2
- package/src/serve/warm-cell.ts +49 -16
- package/src/suite/judge-suites.ts +42 -17
- package/src/suite/suite-cli.ts +5 -8
- package/src/wiki/lint-md.ts +2 -0
- package/src/wiki/scan-md.ts +37 -10
|
@@ -1,24 +1,94 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// (
|
|
1
|
+
// one web app window on macOS: its own app, so the dock and cmd-tab show its
|
|
2
|
+
// own icon, never a second browser. The same source is the shipkit window and
|
|
3
|
+
// every per-process app (home/gui-app.ts builds both with swiftc, no Xcode
|
|
4
|
+
// project): the bundle's Info.plist names the app (CFBundleName) and the url
|
|
5
|
+
// a bare launch opens (ShipkitStartURL). One WKWebView per window; every
|
|
6
|
+
// `open -a <app> <url>` opens one more window, cmd-n too. A link to another
|
|
7
|
+
// site opens in the default browser.
|
|
6
8
|
import AppKit
|
|
7
9
|
import WebKit
|
|
8
10
|
|
|
9
|
-
let
|
|
11
|
+
let info = Bundle.main.infoDictionary ?? [:]
|
|
12
|
+
let appName = (info["CFBundleName"] as? String) ?? "shipkit"
|
|
13
|
+
let startURL = URL(string: (info["ShipkitStartURL"] as? String) ?? "") ?? URL(string: "http://127.0.0.1:4799/")!
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
/** a url that stays in the app: the machine itself, or the host the app is for */
|
|
16
|
+
func stays(_ url: URL) -> Bool {
|
|
17
|
+
url.host == "127.0.0.1" || url.host == "localhost" || url.host == startURL.host
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** a window's content: the web view, and the find bar above it when shown.
|
|
21
|
+
* WKWebView is an NSTextFinderClient, so the platform find bar searches the
|
|
22
|
+
* page; this view is the bar's container */
|
|
23
|
+
final class Pane: NSView, NSTextFinderBarContainer {
|
|
24
|
+
let web: WKWebView
|
|
25
|
+
let finder = NSTextFinder()
|
|
26
|
+
|
|
27
|
+
init(web: WKWebView) {
|
|
28
|
+
self.web = web
|
|
29
|
+
super.init(frame: .zero)
|
|
30
|
+
addSubview(web)
|
|
31
|
+
finder.client = web
|
|
32
|
+
finder.findBarContainer = self
|
|
33
|
+
finder.isIncrementalSearchingEnabled = true
|
|
34
|
+
finder.incrementalSearchingShouldDimContentView = false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
required init?(coder: NSCoder) { fatalError("not from a nib") }
|
|
38
|
+
|
|
39
|
+
override var isFlipped: Bool { true }
|
|
40
|
+
|
|
41
|
+
var findBarView: NSView? {
|
|
42
|
+
didSet {
|
|
43
|
+
oldValue?.removeFromSuperview()
|
|
44
|
+
if let bar = findBarView { addSubview(bar) }
|
|
45
|
+
needsLayout = true
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var isFindBarVisible = false {
|
|
50
|
+
didSet {
|
|
51
|
+
findBarView?.isHidden = !isFindBarVisible
|
|
52
|
+
needsLayout = true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func findBarViewDidChangeHeight() { needsLayout = true }
|
|
57
|
+
|
|
58
|
+
func contentView() -> NSView? { web }
|
|
59
|
+
|
|
60
|
+
override func layout() {
|
|
61
|
+
super.layout()
|
|
62
|
+
var top: CGFloat = 0
|
|
63
|
+
if let bar = findBarView, isFindBarVisible {
|
|
64
|
+
let h = bar.frame.height
|
|
65
|
+
bar.frame = NSRect(x: 0, y: 0, width: bounds.width, height: h)
|
|
66
|
+
top = h
|
|
67
|
+
}
|
|
68
|
+
web.frame = NSRect(x: 0, y: top, width: bounds.width, height: max(0, bounds.height - top))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate, NSMenuItemValidation {
|
|
12
73
|
var windows: [NSWindow] = []
|
|
13
74
|
var opened = false
|
|
14
75
|
|
|
15
76
|
func applicationDidFinishLaunching(_ note: Notification) {
|
|
16
77
|
buildMenu()
|
|
78
|
+
// esc closes the find bar of the key window, and reaches the page otherwise
|
|
79
|
+
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in
|
|
80
|
+
if e.keyCode == 53, let pane = self.keyPane(), pane.isFindBarVisible {
|
|
81
|
+
pane.finder.performAction(.hideFindInterface)
|
|
82
|
+
pane.window?.makeFirstResponder(pane.web)
|
|
83
|
+
return nil
|
|
84
|
+
}
|
|
85
|
+
return e
|
|
86
|
+
}
|
|
17
87
|
// an open event may deliver the url right after launch: wait for it
|
|
18
88
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
19
89
|
if !self.opened {
|
|
20
90
|
let arg = CommandLine.arguments.dropFirst().first { $0.hasPrefix("http") }
|
|
21
|
-
self.open(URL(string:
|
|
91
|
+
self.open(arg.flatMap { URL(string: $0) } ?? startURL)
|
|
22
92
|
}
|
|
23
93
|
}
|
|
24
94
|
}
|
|
@@ -30,26 +100,27 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
30
100
|
func applicationShouldTerminateAfterLastWindowClosed(_ app: NSApplication) -> Bool { true }
|
|
31
101
|
|
|
32
102
|
func applicationShouldHandleReopen(_ app: NSApplication, hasVisibleWindows: Bool) -> Bool {
|
|
33
|
-
if !hasVisibleWindows { open(
|
|
103
|
+
if !hasVisibleWindows { open(startURL) }
|
|
34
104
|
return true
|
|
35
105
|
}
|
|
36
106
|
|
|
107
|
+
func keyPane() -> Pane? { NSApp.keyWindow?.contentView as? Pane }
|
|
108
|
+
|
|
37
109
|
@objc func newWindow(_ sender: Any?) {
|
|
38
|
-
|
|
39
|
-
open(current ?? URL(string: homeFallback)!)
|
|
110
|
+
open(keyPane()?.web.url ?? startURL)
|
|
40
111
|
}
|
|
41
112
|
|
|
42
113
|
@objc func reload(_ sender: Any?) {
|
|
43
|
-
(
|
|
114
|
+
keyPane()?.web.reload()
|
|
44
115
|
}
|
|
45
116
|
|
|
46
|
-
// the zoom is one value for every window, kept across restarts
|
|
117
|
+
// the zoom is one value for every window of the app, kept across restarts
|
|
47
118
|
var zoom: CGFloat {
|
|
48
119
|
get { let z = UserDefaults.standard.double(forKey: "pageZoom"); return z > 0 ? CGFloat(z) : 1 }
|
|
49
120
|
set {
|
|
50
121
|
let z = min(3, max(0.3, newValue))
|
|
51
122
|
UserDefaults.standard.set(Double(z), forKey: "pageZoom")
|
|
52
|
-
for w in windows { (w.contentView as?
|
|
123
|
+
for w in windows { (w.contentView as? Pane)?.web.pageZoom = z }
|
|
53
124
|
}
|
|
54
125
|
}
|
|
55
126
|
|
|
@@ -57,19 +128,51 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
57
128
|
@objc func zoomOut(_ sender: Any?) { zoom = zoom / 1.1 }
|
|
58
129
|
@objc func zoomReset(_ sender: Any?) { zoom = 1 }
|
|
59
130
|
|
|
131
|
+
// the menu item's tag is the NSTextFinder.Action it performs
|
|
132
|
+
@objc func findAction(_ sender: NSMenuItem) {
|
|
133
|
+
guard let pane = keyPane(), let action = NSTextFinder.Action(rawValue: sender.tag) else { return }
|
|
134
|
+
pane.finder.performAction(action)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// no public api opens the inspector: `_inspector` then `show` is the
|
|
138
|
+
// WebKit call the context menu's Inspect Element makes. Where it is gone,
|
|
139
|
+
// say where the inspector still is
|
|
140
|
+
@objc func devTools(_ sender: Any?) {
|
|
141
|
+
guard let web = keyPane()?.web else { return }
|
|
142
|
+
let get = NSSelectorFromString("_inspector"), show = NSSelectorFromString("show")
|
|
143
|
+
if web.responds(to: get), let inspector = web.perform(get)?.takeUnretainedValue() as? NSObject, inspector.responds(to: show) {
|
|
144
|
+
inspector.perform(show)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
let alert = NSAlert()
|
|
148
|
+
alert.messageText = "This WebKit has no way to open the inspector from a menu"
|
|
149
|
+
alert.informativeText = "Right click the page and pick Inspect Element."
|
|
150
|
+
alert.runModal()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
|
154
|
+
if item.action == #selector(findAction(_:)) {
|
|
155
|
+
guard let pane = keyPane(), let action = NSTextFinder.Action(rawValue: item.tag) else { return false }
|
|
156
|
+
return pane.finder.validateAction(action)
|
|
157
|
+
}
|
|
158
|
+
if item.action == #selector(devTools(_:)) || item.action == #selector(reload(_:)) { return keyPane() != nil }
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
|
|
60
162
|
func open(_ url: URL) {
|
|
61
163
|
opened = true
|
|
62
164
|
let config = WKWebViewConfiguration()
|
|
63
165
|
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
|
64
166
|
let web = WKWebView(frame: .zero, configuration: config)
|
|
167
|
+
if #available(macOS 13.3, *) { web.isInspectable = true }
|
|
65
168
|
web.navigationDelegate = self
|
|
66
169
|
web.uiDelegate = self
|
|
67
170
|
web.allowsBackForwardNavigationGestures = true
|
|
68
171
|
web.pageZoom = zoom
|
|
69
172
|
let frame = NSRect(x: 0, y: 0, width: 1400, height: 900)
|
|
70
173
|
let win = NSWindow(contentRect: frame, styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
|
|
71
|
-
win.contentView = web
|
|
72
|
-
win.title =
|
|
174
|
+
win.contentView = Pane(web: web)
|
|
175
|
+
win.title = appName
|
|
73
176
|
win.delegate = self
|
|
74
177
|
win.isReleasedWhenClosed = false
|
|
75
178
|
win.setFrameAutosaveName("shipkit-window")
|
|
@@ -77,6 +180,7 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
77
180
|
windows.append(win)
|
|
78
181
|
web.load(URLRequest(url: url))
|
|
79
182
|
win.makeKeyAndOrderFront(nil)
|
|
183
|
+
win.makeFirstResponder(web)
|
|
80
184
|
NSApp.activate(ignoringOtherApps: true)
|
|
81
185
|
}
|
|
82
186
|
|
|
@@ -84,35 +188,40 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
84
188
|
windows.removeAll { $0 === note.object as? NSWindow }
|
|
85
189
|
}
|
|
86
190
|
|
|
87
|
-
// the page title names the window, so cmd-tab and the window menu tell
|
|
191
|
+
// the page title names the window, so cmd-tab and the window menu tell pages apart
|
|
88
192
|
func webView(_ web: WKWebView, didFinish nav: WKNavigation!) {
|
|
89
|
-
web.window?.title = (web.title?.isEmpty == false) ? web.title! :
|
|
193
|
+
web.window?.title = (web.title?.isEmpty == false) ? web.title! : appName
|
|
90
194
|
}
|
|
91
195
|
|
|
92
|
-
//
|
|
196
|
+
// the app's own host: stay in the window; anything else: the default browser
|
|
93
197
|
func webView(_ web: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
94
198
|
guard let url = action.request.url, action.navigationType == .linkActivated else { return decisionHandler(.allow) }
|
|
95
|
-
|
|
96
|
-
if local { return decisionHandler(.allow) }
|
|
199
|
+
if stays(url) { return decisionHandler(.allow) }
|
|
97
200
|
NSWorkspace.shared.open(url)
|
|
98
201
|
decisionHandler(.cancel)
|
|
99
202
|
}
|
|
100
203
|
|
|
101
|
-
// window.open and target=_blank: one more
|
|
204
|
+
// window.open and target=_blank: one more window of this app for its own pages
|
|
102
205
|
func webView(_ web: WKWebView, createWebViewWith config: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
|
103
206
|
if let url = action.request.url {
|
|
104
|
-
if url
|
|
207
|
+
if stays(url) { open(url) } else { NSWorkspace.shared.open(url) }
|
|
105
208
|
}
|
|
106
209
|
return nil
|
|
107
210
|
}
|
|
108
211
|
|
|
212
|
+
func item(_ menu: NSMenu, _ title: String, _ action: Selector, _ key: String, _ mods: NSEvent.ModifierFlags = [.command], tag: Int = 0) {
|
|
213
|
+
let i = menu.addItem(withTitle: title, action: action, keyEquivalent: key)
|
|
214
|
+
i.keyEquivalentModifierMask = mods
|
|
215
|
+
i.tag = tag
|
|
216
|
+
}
|
|
217
|
+
|
|
109
218
|
func buildMenu() {
|
|
110
219
|
let main = NSMenu()
|
|
111
220
|
let appItem = NSMenuItem()
|
|
112
221
|
main.addItem(appItem)
|
|
113
222
|
let appMenu = NSMenu()
|
|
114
|
-
appMenu.addItem(withTitle: "Hide
|
|
115
|
-
appMenu.addItem(withTitle: "Quit
|
|
223
|
+
appMenu.addItem(withTitle: "Hide \(appName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
|
224
|
+
appMenu.addItem(withTitle: "Quit \(appName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
|
116
225
|
appItem.submenu = appMenu
|
|
117
226
|
let fileItem = NSMenuItem()
|
|
118
227
|
main.addItem(fileItem)
|
|
@@ -130,6 +239,14 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
130
239
|
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
|
131
240
|
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
|
132
241
|
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
|
242
|
+
edit.addItem(.separator())
|
|
243
|
+
let findItem = edit.addItem(withTitle: "Find", action: nil, keyEquivalent: "")
|
|
244
|
+
let find = NSMenu(title: "Find")
|
|
245
|
+
item(find, "Find…", #selector(findAction(_:)), "f", tag: NSTextFinder.Action.showFindInterface.rawValue)
|
|
246
|
+
item(find, "Find Next", #selector(findAction(_:)), "g", tag: NSTextFinder.Action.nextMatch.rawValue)
|
|
247
|
+
item(find, "Find Previous", #selector(findAction(_:)), "g", [.command, .shift], tag: NSTextFinder.Action.previousMatch.rawValue)
|
|
248
|
+
item(find, "Hide Find Bar", #selector(findAction(_:)), "", [], tag: NSTextFinder.Action.hideFindInterface.rawValue)
|
|
249
|
+
edit.setSubmenu(find, for: findItem)
|
|
133
250
|
editItem.submenu = edit
|
|
134
251
|
let viewItem = NSMenuItem()
|
|
135
252
|
main.addItem(viewItem)
|
|
@@ -141,6 +258,8 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
141
258
|
alsoIn.isAlternate = true
|
|
142
259
|
alsoIn.keyEquivalentModifierMask = [.command]
|
|
143
260
|
view.addItem(withTitle: "Zoom Out", action: #selector(zoomOut(_:)), keyEquivalent: "-")
|
|
261
|
+
view.addItem(.separator())
|
|
262
|
+
item(view, "Developer Tools", #selector(devTools(_:)), "i", [.command, .option])
|
|
144
263
|
viewItem.submenu = view
|
|
145
264
|
let winItem = NSMenuItem()
|
|
146
265
|
main.addItem(winItem)
|
package/src/home/gui-app.ts
CHANGED
|
@@ -1,90 +1,122 @@
|
|
|
1
|
-
// the macOS
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// the macOS apps shipkit opens web pages in: ONE source, ShipkitGui.swift,
|
|
2
|
+
// built with swiftc into a bundle per page it serves. `shipkit gui` is that
|
|
3
|
+
// app pointed at the home; each global process with a url gets its own, named
|
|
4
|
+
// and iconed after it, in its repo's `.shipkit/wrapper/`. The bundle's
|
|
5
|
+
// Info.plist carries what differs (name, bundle id, start url), so a fix to
|
|
6
|
+
// the window (find, the inspector, zoom) lands in every app at its next
|
|
7
|
+
// build. Rebuilt only when the source, the icon or the start url changed (a
|
|
8
|
+
// stamp in the bundle). No compiler, or not macOS: the caller falls back.
|
|
6
9
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
7
10
|
import { homedir } from 'os'
|
|
8
11
|
import { dirname, join } from 'path'
|
|
9
12
|
import { fileURLToPath } from 'url'
|
|
13
|
+
import { LAYOUT } from '../core/layout.ts'
|
|
10
14
|
|
|
11
15
|
const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.url))
|
|
12
16
|
|
|
13
17
|
export const BUNDLE_ID = 'dev.shipkit.gui'
|
|
14
18
|
export const guiAppPath = (): string => process.env.SHIPKIT_GUI_APP ?? join(homedir(), 'Applications', 'Shipkit.app')
|
|
15
19
|
|
|
16
|
-
/** the icon the app wears: the
|
|
20
|
+
/** the icon the app wears: the repo's own mark, largest first. Only a
|
|
17
21
|
* png can become an icns */
|
|
18
|
-
export const APP_ICON_FILES = [
|
|
22
|
+
export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
|
|
19
23
|
|
|
20
24
|
export function appIconOf(root: string): string | null {
|
|
21
25
|
for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
|
|
22
26
|
return null
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
/** what one app bundle is: the rest comes from the shared source */
|
|
30
|
+
export type AppSpec = {
|
|
31
|
+
/** CFBundleName: the dock, cmd-tab and the app menu say it */
|
|
32
|
+
name: string
|
|
33
|
+
bundleId: string
|
|
34
|
+
/** the page a launch with no url opens; null opens the home */
|
|
35
|
+
url: string | null
|
|
36
|
+
icon: string | null
|
|
37
|
+
/** where the bundle lands */
|
|
38
|
+
app: string
|
|
39
|
+
version: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const xml = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
43
|
+
|
|
44
|
+
/** plain http to a host that is not local needs its own ATS exception; the
|
|
45
|
+
* local network is allowed to every app */
|
|
46
|
+
function atsOf(url: string | null): string {
|
|
47
|
+
let u: URL | null = null
|
|
48
|
+
try { u = url == null ? null : new URL(url) } catch { /* not a url: the local network only */ }
|
|
49
|
+
const host = u?.protocol === 'http:' ? u.hostname : null
|
|
50
|
+
const local = host == null || host === 'localhost' || /^[\d.]+$/.test(host) || !host.includes('.') || host.endsWith('.local')
|
|
51
|
+
const exception = local ? '' : `<key>NSExceptionDomains</key><dict><key>${xml(host)}</key><dict><key>NSExceptionAllowsInsecureHTTPLoads</key><true/></dict></dict>`
|
|
52
|
+
return `<dict><key>NSAllowsLocalNetworking</key><true/>${exception}</dict>`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function infoPlist(o: Omit<AppSpec, 'app' | 'icon'>): string {
|
|
26
56
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
57
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
58
|
<plist version="1.0">
|
|
29
59
|
<dict>
|
|
30
|
-
\t<key>CFBundleName</key><string
|
|
31
|
-
\t<key>CFBundleDisplayName</key><string
|
|
32
|
-
\t<key>CFBundleIdentifier</key><string>${
|
|
60
|
+
\t<key>CFBundleName</key><string>${xml(o.name)}</string>
|
|
61
|
+
\t<key>CFBundleDisplayName</key><string>${xml(o.name)}</string>
|
|
62
|
+
\t<key>CFBundleIdentifier</key><string>${xml(o.bundleId)}</string>
|
|
33
63
|
\t<key>CFBundleExecutable</key><string>shipkit-gui</string>
|
|
34
64
|
\t<key>CFBundleIconFile</key><string>AppIcon</string>
|
|
35
65
|
\t<key>CFBundlePackageType</key><string>APPL</string>
|
|
36
|
-
\t<key>CFBundleShortVersionString</key><string>${o.version}</string>
|
|
37
|
-
\t<key>CFBundleVersion</key><string>${o.version}</string>
|
|
38
|
-
\t<key>LSMinimumSystemVersion</key><string>12.0</string>
|
|
66
|
+
\t<key>CFBundleShortVersionString</key><string>${xml(o.version)}</string>
|
|
67
|
+
\t<key>CFBundleVersion</key><string>${xml(o.version)}</string>
|
|
68
|
+
${o.url == null ? '' : `\t<key>ShipkitStartURL</key><string>${xml(o.url)}</string>\n`}\t<key>LSMinimumSystemVersion</key><string>12.0</string>
|
|
39
69
|
\t<key>NSHighResolutionCapable</key><true/>
|
|
40
|
-
\t<key>NSAppTransportSecurity</key
|
|
70
|
+
\t<key>NSAppTransportSecurity</key>${atsOf(o.url)}
|
|
41
71
|
</dict>
|
|
42
72
|
</plist>
|
|
43
73
|
`
|
|
44
74
|
}
|
|
45
75
|
|
|
46
|
-
type Run = (cmd: string[]) => { code: number; err: string }
|
|
76
|
+
export type Run = (cmd: string[]) => { code: number; err: string }
|
|
47
77
|
const runSync: Run = (cmd) => {
|
|
48
78
|
const p = Bun.spawnSync(cmd, { stdout: 'ignore', stderr: 'pipe' })
|
|
49
79
|
return { code: p.exitCode ?? 1, err: p.stderr.toString().trim() }
|
|
50
80
|
}
|
|
51
81
|
|
|
52
|
-
/** the stamp a built bundle carries: what it was built from
|
|
53
|
-
|
|
54
|
-
|
|
82
|
+
/** the stamp a built bundle carries: what it was built from. The version is
|
|
83
|
+
* left out, so a shipkit release rebuilds nothing */
|
|
84
|
+
export function stampOf(source: string, icon: string | null, identity = ''): string {
|
|
85
|
+
const h = new Bun.CryptoHasher('sha256').update(source).update(identity)
|
|
55
86
|
if (icon != null) h.update(readFileSync(icon))
|
|
56
87
|
return h.digest('hex').slice(0, 16)
|
|
57
88
|
}
|
|
58
89
|
|
|
59
|
-
/**
|
|
90
|
+
/** one app, built or rebuilt when stale. Throws with the step that failed.
|
|
60
91
|
* `rebuilt` tells the caller a running copy is now the old one */
|
|
61
|
-
export function
|
|
62
|
-
const app
|
|
92
|
+
export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): { app: string; rebuilt: boolean } {
|
|
93
|
+
const { app, icon } = spec
|
|
63
94
|
const run = o.run ?? runSync
|
|
64
|
-
const
|
|
65
|
-
const stamp = stampOf(readFileSync(
|
|
95
|
+
const source = o.source ?? SOURCE
|
|
96
|
+
const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url]))
|
|
66
97
|
const stampFile = join(app, 'Contents', 'Resources', 'stamp')
|
|
67
98
|
if (existsSync(stampFile) && readFileSync(stampFile, 'utf8') === stamp) return { app, rebuilt: false }
|
|
68
99
|
const tmp = `${app}.building-${process.pid}`
|
|
100
|
+
const fail = (msg: string): never => { rmSync(tmp, { recursive: true, force: true }); throw new Error(msg) }
|
|
69
101
|
rmSync(tmp, { recursive: true, force: true })
|
|
70
102
|
const res = join(tmp, 'Contents', 'Resources')
|
|
71
103
|
mkdirSync(join(tmp, 'Contents', 'MacOS'), { recursive: true })
|
|
72
104
|
mkdirSync(res, { recursive: true })
|
|
73
|
-
writeFileSync(join(tmp, 'Contents', 'Info.plist'), infoPlist(
|
|
74
|
-
const built = run(['swiftc', '-O', '-o', join(tmp, 'Contents', 'MacOS', 'shipkit-gui'),
|
|
75
|
-
if (built.code !== 0)
|
|
105
|
+
writeFileSync(join(tmp, 'Contents', 'Info.plist'), infoPlist(spec))
|
|
106
|
+
const built = run(['swiftc', '-O', '-o', join(tmp, 'Contents', 'MacOS', 'shipkit-gui'), source])
|
|
107
|
+
if (built.code !== 0) fail(`swiftc could not build ${spec.name}.app: ${built.err.slice(0, 400)}`)
|
|
76
108
|
if (icon != null) {
|
|
77
109
|
const set = join(res, 'AppIcon.iconset')
|
|
78
110
|
mkdirSync(set, { recursive: true })
|
|
79
111
|
for (const n of [16, 32, 128, 256, 512]) {
|
|
80
112
|
for (const [scale, px] of [['', n], ['@2x', n * 2]] as const) {
|
|
81
|
-
const r = run(['sips', '-z', String(px), String(px), icon, '--out', join(set, `icon_${n}x${n}${scale}.png`)])
|
|
82
|
-
if (r.code !== 0)
|
|
113
|
+
const r = run(['sips', '-s', 'format', 'png', '-z', String(px), String(px), icon, '--out', join(set, `icon_${n}x${n}${scale}.png`)])
|
|
114
|
+
if (r.code !== 0) fail(`sips could not size the icon ${icon}: ${r.err.slice(0, 200)}`)
|
|
83
115
|
}
|
|
84
116
|
}
|
|
85
117
|
const r = run(['iconutil', '-c', 'icns', set, '-o', join(res, 'AppIcon.icns')])
|
|
86
118
|
rmSync(set, { recursive: true, force: true })
|
|
87
|
-
if (r.code !== 0)
|
|
119
|
+
if (r.code !== 0) fail(`iconutil could not build the icon: ${r.err.slice(0, 200)}`)
|
|
88
120
|
}
|
|
89
121
|
writeFileSync(join(res, 'stamp'), stamp)
|
|
90
122
|
mkdirSync(dirname(app), { recursive: true })
|
|
@@ -95,5 +127,36 @@ export function ensureGuiApp(o: { root: string; version: string; app?: string; r
|
|
|
95
127
|
return { app, rebuilt: true }
|
|
96
128
|
}
|
|
97
129
|
|
|
130
|
+
/** the shipkit app: the shared source on the home, the house repo's icon */
|
|
131
|
+
export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): { app: string; rebuilt: boolean } {
|
|
132
|
+
return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** what a global process gives its app */
|
|
136
|
+
export type WrapperOf = { id: string; label: string; url: string | null; icon: string | null; root: string }
|
|
137
|
+
|
|
138
|
+
/** the image icons sips reads; an emoji or an svg leaves the repo's mark */
|
|
139
|
+
const RASTER = /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i
|
|
140
|
+
|
|
141
|
+
export const wrapperBundleId = (id: string): string => `dev.shipkit.app.${id.replace(/[^A-Za-z0-9-]+/g, '-')}`
|
|
142
|
+
export const wrapperPath = (root: string, id: string): string => join(root, LAYOUT.wrapper, `${id.replace(/[^A-Za-z0-9._-]+/g, '-')}.app`)
|
|
143
|
+
|
|
144
|
+
/** the spec of a process's own app: its label, its declared image icon or
|
|
145
|
+
* else its repo's mark, in its repo's `.shipkit/wrapper/`. Throws for a
|
|
146
|
+
* process with no url: there is no page to wrap */
|
|
147
|
+
export function wrapperSpec(d: WrapperOf, version: string): AppSpec {
|
|
148
|
+
if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
|
|
149
|
+
const declared = d.icon != null && RASTER.test(d.icon) && existsSync(join(d.root, d.icon)) ? join(d.root, d.icon) : null
|
|
150
|
+
return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), app: wrapperPath(d.root, d.id), version }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const ensureWrapperApp = (d: WrapperOf, o: { version: string; run?: Run }): { app: string; rebuilt: boolean; bundleId: string } => {
|
|
154
|
+
const spec = wrapperSpec(d, o.version)
|
|
155
|
+
return { ...ensureApp(spec, { run: o.run }), bundleId: spec.bundleId }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** quits a running copy of an app, and launches nothing when none runs */
|
|
159
|
+
export const quitCmd = (bundleId: string): string[] => ['osascript', '-e', `if application id "${bundleId}" is running then quit application id "${bundleId}"`]
|
|
160
|
+
|
|
98
161
|
/** true when this machine can build the app */
|
|
99
162
|
export const canBuildGui = (platform = process.platform): boolean => platform === 'darwin' && Bun.which('swiftc') != null && Bun.which('iconutil') != null
|
package/src/home/home-launchd.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// dies. macOS only; launchd is the one supervisor of the one process that must
|
|
3
3
|
// always run, and pm supervises everything else (the daemon, the web, the
|
|
4
4
|
// machine resources).
|
|
5
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
6
6
|
import { homedir } from 'os'
|
|
7
7
|
import { join } from 'path'
|
|
8
8
|
import { homeDir, pmLog } from './home-paths.ts'
|
|
@@ -55,7 +55,15 @@ ${args}
|
|
|
55
55
|
`
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
/** the house repo the installed pm runs from, read back from its agent: the
|
|
59
|
+
* one answer to "which house" that does not depend on cwd */
|
|
60
|
+
export function installedHouseRoot(file = plistPath()): string | null {
|
|
61
|
+
if (!existsSync(file)) return null
|
|
62
|
+
const m = /<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/.exec(readFileSync(file, 'utf8'))
|
|
63
|
+
return m == null ? null : m[1]!.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Launchctl =(args: string[]) => Promise<{ code: number; out: string }>
|
|
59
67
|
|
|
60
68
|
const launchctl: Launchctl = async (args) => {
|
|
61
69
|
const p = Bun.spawn(['launchctl', ...args], { stdout: 'pipe', stderr: 'pipe' })
|
package/src/home/home-routes.ts
CHANGED
|
@@ -179,7 +179,8 @@ export function homeRoutes(d: HomeDeps) {
|
|
|
179
179
|
return Response.json(view, { headers: { 'cache-control': 'no-store' } })
|
|
180
180
|
}
|
|
181
181
|
if (path === '/api/home/widgets.json') {
|
|
182
|
-
|
|
182
|
+
if (d.widgets != null && repos.length === 0) await refresh()
|
|
183
|
+
const out: WidgetsAnswer = { widgets: (await d.widgets?.look(repos.map((r) => ({ name: r.name, path: r.path })))) ?? [], errors: d.widgets?.errors ?? [] }
|
|
183
184
|
return Response.json(out)
|
|
184
185
|
}
|
|
185
186
|
if (path === '/api/home.json') {
|
package/src/home/home-start.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { homedir } from 'os'
|
|
|
6
6
|
import { dirname, join, resolve } from 'path'
|
|
7
7
|
import { gitRoot } from '../core/git-root.ts'
|
|
8
8
|
import { homeRepo, houseIndex } from '../serve/project-gutter.ts'
|
|
9
|
-
import { BUNDLE_ID, canBuildGui, ensureGuiApp } from './gui-app.ts'
|
|
10
|
-
import { LABEL, plistPath } from './home-launchd.ts'
|
|
9
|
+
import { BUNDLE_ID, canBuildGui, ensureGuiApp, ensureWrapperApp, quitCmd, type WrapperOf } from './gui-app.ts'
|
|
10
|
+
import { installedHouseRoot, LABEL, plistPath } from './home-launchd.ts'
|
|
11
11
|
import { childLog, daemonPort, daemonUp, homeDir, homePort, homeUp, pmLog } from './home-paths.ts'
|
|
12
12
|
|
|
13
13
|
/** the pm LaunchAgent is installed: on this machine no hub runs outside the home */
|
|
@@ -65,7 +65,11 @@ export function houseRootOf(cwd: string): string | null {
|
|
|
65
65
|
return gitRoot(dirname(index))?.top ?? null
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
/** the house the shipkit app wears the mark of: the one cwd sits in, else the
|
|
69
|
+
* one the installed pm runs from. Never cwd itself, which carries no mark */
|
|
70
|
+
export const guiRoot = (cwd: string, plist = plistPath()): string | null => houseRootOf(cwd) ?? installedHouseRoot(plist)
|
|
71
|
+
|
|
72
|
+
export const CHROME =process.env.CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
69
73
|
/** the window's own Chrome profile: its own dock icon and cookies, apart from the everyday browser */
|
|
70
74
|
export const guiProfile = (): string => join(homeDir(), 'gui-profile')
|
|
71
75
|
const GUI_DISK_CACHE = 32 * 1024 * 1024
|
|
@@ -87,9 +91,9 @@ export function windowCmd(url: string, o: { chrome?: string; platform?: string }
|
|
|
87
91
|
export function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): string[] {
|
|
88
92
|
if (canBuildGui()) {
|
|
89
93
|
try {
|
|
90
|
-
const { app, rebuilt } = ensureGuiApp({ root: o.root ??
|
|
94
|
+
const { app, rebuilt } = ensureGuiApp({ root: o.root ?? guiRoot(process.cwd()),version: o.version ?? '0' })
|
|
91
95
|
// a running copy is the old build: it quits, and the new one opens the window
|
|
92
|
-
if (rebuilt) Bun.spawnSync(
|
|
96
|
+
if (rebuilt) Bun.spawnSync(quitCmd(BUNDLE_ID), { stdout: 'ignore', stderr: 'ignore' })
|
|
93
97
|
const cmd = ['open', '-a', app, url]
|
|
94
98
|
Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'ignore'] })
|
|
95
99
|
return cmd
|
|
@@ -97,6 +101,30 @@ export function openWindow(url: string, o: { root?: string | null; version?: str
|
|
|
97
101
|
o.warn?.(`WARN the shipkit app could not be built, so this window is a Chrome one: ${(e as Error).message}`)
|
|
98
102
|
}
|
|
99
103
|
}
|
|
104
|
+
return chromeWindow(url)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** a global process's own app: built on first use in its repo's
|
|
108
|
+
* `.shipkit/wrapper/`, then opened with no url, so a running copy comes to
|
|
109
|
+
* the front instead of stacking windows. Where no app can be built, a
|
|
110
|
+
* Chrome app window on its url */
|
|
111
|
+
export function openProcessApp(d: WrapperOf, o: { version: string; warn?: (l: string) => void }): string[] {
|
|
112
|
+
if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
|
|
113
|
+
if (canBuildGui()) {
|
|
114
|
+
try {
|
|
115
|
+
const { app, rebuilt, bundleId } = ensureWrapperApp(d, { version: o.version })
|
|
116
|
+
if (rebuilt) Bun.spawnSync(quitCmd(bundleId), { stdout: 'ignore', stderr: 'ignore' })
|
|
117
|
+
const cmd = ['open', '-a', app]
|
|
118
|
+
Bun.spawnSync(cmd, { stdout: 'ignore', stderr: 'ignore' })
|
|
119
|
+
return cmd
|
|
120
|
+
} catch (e) {
|
|
121
|
+
o.warn?.(`WARN the app for ${d.id} could not be built, so this window is a Chrome one: ${(e as Error).message}`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return chromeWindow(d.url)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function chromeWindow(url: string): string[] {
|
|
100
128
|
mkdirSync(guiProfile(), { recursive: true })
|
|
101
129
|
const cmd = windowCmd(url)
|
|
102
130
|
// detached: the window outlives the command that opened it
|
|
@@ -91,8 +91,9 @@ export const BOOT_SWEEP_DELAY_MS = 10_000
|
|
|
91
91
|
export type Sweeper = { stop: () => void; once: () => Promise<void> }
|
|
92
92
|
|
|
93
93
|
/** the daemon's one retention sweep: every repo it loads, then the machine,
|
|
94
|
-
* on boot and every hour. A
|
|
95
|
-
|
|
94
|
+
* then `also` (the machine's batched writes), on boot and every hour. A
|
|
95
|
+
* store whose sweep throws is one printed line */
|
|
96
|
+
export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (l: string) => void; everyMs?: number; bootDelayMs?: number; now?: () => number; also?: (now: number) => void }): Sweeper {
|
|
96
97
|
let running = false
|
|
97
98
|
const once = async (): Promise<void> => {
|
|
98
99
|
if (running) return
|
|
@@ -103,6 +104,7 @@ export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (
|
|
|
103
104
|
for (const r of await sweepStores(repoStores(root), now)) if (r.error != null) o.printErr(`WARN retention sweep of ${r.id} in ${root} failed: ${r.error}`)
|
|
104
105
|
}
|
|
105
106
|
for (const r of await sweepStores(machineStores(o.dir), now)) if (r.error != null) o.printErr(`WARN retention sweep of ${r.id} failed: ${r.error}`)
|
|
107
|
+
o.also?.(now)
|
|
106
108
|
} finally {
|
|
107
109
|
running = false
|
|
108
110
|
}
|
package/src/home/open-app.ts
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
// opens one shipkit app window
|
|
2
|
-
//
|
|
1
|
+
// opens one shipkit app window, in its own short process, so pm never loads
|
|
2
|
+
// what building an app needs:
|
|
3
3
|
//
|
|
4
|
-
// bun open-app.ts <url>
|
|
4
|
+
// bun open-app.ts <url> one more shipkit window on a url
|
|
5
|
+
// bun open-app.ts --process <json> a global process's own app (WrapperOf)
|
|
5
6
|
import { LIB_VERSION } from '../core/lib-version.ts'
|
|
6
|
-
import {
|
|
7
|
+
import type { WrapperOf } from './gui-app.ts'
|
|
8
|
+
import { openProcessApp, openWindow } from './home-start.ts'
|
|
7
9
|
|
|
8
10
|
if (import.meta.main) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
process
|
|
11
|
+
const [first, second] = process.argv.slice(2)
|
|
12
|
+
const warn = (l: string): void => console.error(l)
|
|
13
|
+
try {
|
|
14
|
+
if (first === '--process' && second != null) {
|
|
15
|
+
const d = JSON.parse(second) as WrapperOf
|
|
16
|
+
const cmd = openProcessApp(d, { version: LIB_VERSION, warn })
|
|
17
|
+
console.log(`shipkit: opened ${d.id} as its own app: ${cmd.join(' ')}`)
|
|
18
|
+
} else if (first != null && first !== '--process') {
|
|
19
|
+
const cmd = openWindow(first, { version: LIB_VERSION, warn })
|
|
20
|
+
console.log(`shipkit: opened ${first} as an app window: ${cmd.join(' ')}`)
|
|
21
|
+
} else {
|
|
22
|
+
console.error('usage: bun open-app.ts <url> | --process <json>')
|
|
23
|
+
process.exit(2)
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.error(`shipkit: could not open the app: ${(e as Error).message}`)
|
|
27
|
+
process.exit(1)
|
|
13
28
|
}
|
|
14
|
-
const cmd = openWindow(url, { version: LIB_VERSION, warn: (l) => console.error(l) })
|
|
15
|
-
console.log(`shipkit: opened ${url} as an app window: ${cmd.join(' ')}`)
|
|
16
29
|
}
|