rvlib-shipkit 1.2.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.
Files changed (173) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +15 -10
  7. package/src/check.ts +26 -10
  8. package/src/checks/SK001-package-json-shape.check.ts +34 -5
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  11. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  12. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  13. package/src/checks/SK007-github-visibility.check.ts +9 -9
  14. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  15. package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
  16. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  17. package/src/checks/SK014-prose-ste.check.ts +16 -6
  18. package/src/checks/SK015-nested-repo.check.ts +5 -1
  19. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  20. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  21. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  22. package/src/checks/SK024-identity-colors.check.ts +5 -0
  23. package/src/checks/SK025-release-drift.check.ts +58 -0
  24. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  25. package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
  26. package/src/checks/SK028-readme.check.ts +83 -0
  27. package/src/cli/cmd_Assets.ts +1 -1
  28. package/src/cli/cmd_Ci.ts +5 -0
  29. package/src/cli/cmd_CrossRepoActivity.ts +119 -0
  30. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  31. package/src/cli/cmd_Keys.ts +17 -3
  32. package/src/cli/cmd_Overview.ts +98 -0
  33. package/src/cli/cmd_Planned.ts +117 -0
  34. package/src/cli.ts +9 -1
  35. package/src/collections/checks.ts +3 -2
  36. package/src/collections/collection-types.ts +15 -0
  37. package/src/collections/providers.ts +7 -3
  38. package/src/collections/test-time.ts +10 -1
  39. package/src/contracts/apply-contracts.ts +9 -8
  40. package/src/contracts/contract-types.ts +8 -2
  41. package/src/contracts/contracts-engine.ts +34 -13
  42. package/src/contracts/manifests/identity.contract.ts +6 -5
  43. package/src/contracts/manifests/social.contract.ts +4 -2
  44. package/src/contracts/manifests/steam.contract.ts +7 -3
  45. package/src/contracts/web-surface.ts +59 -0
  46. package/src/core/agent-spawn.ts +46 -0
  47. package/src/core/archived.ts +82 -0
  48. package/src/core/budget.ts +19 -9
  49. package/src/core/canonical-scripts.ts +12 -3
  50. package/src/core/check-runner.ts +66 -17
  51. package/src/core/discover-checks.ts +10 -5
  52. package/src/core/file-stamp.ts +20 -0
  53. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  54. package/src/core/identity-color.ts +1 -1
  55. package/src/core/layout.ts +2 -0
  56. package/src/core/motherhome.ts +88 -66
  57. package/src/core/repo-facts.ts +14 -0
  58. package/src/core/retention.ts +14 -5
  59. package/src/core/retired-checks.ts +7 -0
  60. package/src/core/run-check.ts +8 -7
  61. package/src/core/run-ci.ts +46 -15
  62. package/src/core/run-fix.ts +7 -2
  63. package/src/core/run-fixers.ts +10 -6
  64. package/src/core/wire.ts +49 -9
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +138 -0
  67. package/src/home/activity.ts +256 -0
  68. package/src/home/global-units.ts +3 -2
  69. package/src/home/gui-app/ShipkitGui.swift +144 -25
  70. package/src/home/gui-app.ts +93 -30
  71. package/src/home/home-launchd.ts +10 -2
  72. package/src/home/home-routes.ts +34 -6
  73. package/src/home/home-start.ts +33 -5
  74. package/src/home/machine-stores.ts +4 -2
  75. package/src/home/open-app.ts +23 -10
  76. package/src/home/pm-config.ts +15 -11
  77. package/src/home/pm-inputs.ts +76 -0
  78. package/src/home/pm.ts +40 -7
  79. package/src/home/process-rows.ts +222 -0
  80. package/src/home/resources.ts +99 -20
  81. package/src/home/run-daemon.ts +12 -7
  82. package/src/home/run-pm.ts +30 -5
  83. package/src/home/serve-web.ts +6 -3
  84. package/src/home/supervise.ts +16 -1
  85. package/src/home/widget-text.ts +119 -0
  86. package/src/home/widgets.ts +32 -3
  87. package/src/hooks/commit-message.ts +2 -3
  88. package/src/hooks/run-hooks.ts +32 -5
  89. package/src/ledger/db.ts +1 -1
  90. package/src/ledger/gate-checks.ts +20 -6
  91. package/src/ledger/house-runs.ts +43 -0
  92. package/src/ledger/live-timeline.ts +4 -19
  93. package/src/ledger/run-log.ts +39 -10
  94. package/src/ledger/store.ts +30 -2
  95. package/src/ledger/world-cache.ts +63 -14
  96. package/src/plan/cadence.ts +21 -5
  97. package/src/plan/catch-up.ts +37 -0
  98. package/src/plan/plan-timeline.ts +99 -0
  99. package/src/plan/planner.ts +138 -13
  100. package/src/plan/repo-source.ts +120 -8
  101. package/src/release/run-release.ts +9 -2
  102. package/src/release/unit-resolve.ts +8 -12
  103. package/src/repo-config.ts +17 -4
  104. package/src/serve/house-runs.ts +53 -0
  105. package/src/serve/hub-app/activity-st.ts +168 -0
  106. package/src/serve/hub-app/activity-view.tsx +218 -0
  107. package/src/serve/hub-app/activity.css +78 -0
  108. package/src/serve/hub-app/admin-svc.ts +41 -0
  109. package/src/serve/hub-app/admin-view.tsx +42 -0
  110. package/src/serve/hub-app/chart-view.ts +12 -5
  111. package/src/serve/hub-app/check-words.ts +2 -0
  112. package/src/serve/hub-app/checks-svc.ts +2 -1
  113. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  114. package/src/serve/hub-app/col-tables.ts +115 -0
  115. package/src/serve/hub-app/cols-svc.ts +28 -0
  116. package/src/serve/hub-app/controls.css +32 -0
  117. package/src/serve/hub-app/cycle-button.tsx +36 -0
  118. package/src/serve/hub-app/dock-anim.ts +25 -0
  119. package/src/serve/hub-app/dock-st.ts +329 -0
  120. package/src/serve/hub-app/dock-view.tsx +68 -0
  121. package/src/serve/hub-app/dock.css +35 -0
  122. package/src/serve/hub-app/hash-route.ts +7 -1
  123. package/src/serve/hub-app/header-bar.tsx +79 -104
  124. package/src/serve/hub-app/home-st.ts +50 -13
  125. package/src/serve/hub-app/home-strip.css +81 -23
  126. package/src/serve/hub-app/home-strip.tsx +243 -102
  127. package/src/serve/hub-app/home.html +4 -0
  128. package/src/serve/hub-app/hub-app.tsx +103 -161
  129. package/src/serve/hub-app/hub-types.ts +6 -3
  130. package/src/serve/hub-app/hub.css +101 -39
  131. package/src/serve/hub-app/icons.tsx +27 -3
  132. package/src/serve/hub-app/index.html +4 -0
  133. package/src/serve/hub-app/keys.ts +44 -9
  134. package/src/serve/hub-app/live-svc.ts +24 -64
  135. package/src/serve/hub-app/machine-st.ts +65 -13
  136. package/src/serve/hub-app/md.ts +38 -5
  137. package/src/serve/hub-app/next-run.ts +1 -1
  138. package/src/serve/hub-app/palette-st.ts +13 -9
  139. package/src/serve/hub-app/palette-view.tsx +47 -0
  140. package/src/serve/hub-app/pill-row.tsx +65 -0
  141. package/src/serve/hub-app/planned-st.ts +21 -85
  142. package/src/serve/hub-app/planned-view.tsx +12 -7
  143. package/src/serve/hub-app/planned.css +5 -2
  144. package/src/serve/hub-app/project-list.css +43 -21
  145. package/src/serve/hub-app/project-list.tsx +122 -23
  146. package/src/serve/hub-app/projects-svc.ts +120 -12
  147. package/src/serve/hub-app/readme-svc.ts +38 -0
  148. package/src/serve/hub-app/readme-view.tsx +19 -0
  149. package/src/serve/hub-app/readme.css +11 -0
  150. package/src/serve/hub-app/repo-sections.tsx +61 -0
  151. package/src/serve/hub-app/repo-st.ts +26 -7
  152. package/src/serve/hub-app/runs-tabs.ts +1 -1
  153. package/src/serve/hub-app/sidebar.tsx +84 -0
  154. package/src/serve/hub-app/tests-view.tsx +31 -4
  155. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  156. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  157. package/src/serve/hub-app/timeline-svc.ts +2 -2
  158. package/src/serve/hub-app/timeline-view.tsx +29 -9
  159. package/src/serve/hub-runtime.ts +17 -9
  160. package/src/serve/other-repos.ts +8 -45
  161. package/src/serve/project-gutter-route.ts +100 -20
  162. package/src/serve/project-gutter.ts +34 -13
  163. package/src/serve/readme-route.ts +57 -0
  164. package/src/serve/serve-hub.ts +13 -8
  165. package/src/serve/tracked-watch.ts +33 -1
  166. package/src/serve/warm-cell.ts +49 -16
  167. package/src/shipkit-files.ts +20 -4
  168. package/src/suite/judge-suites.ts +42 -17
  169. package/src/suite/suite-cli.ts +5 -8
  170. package/src/wiki/lint-md.ts +2 -0
  171. package/src/wiki/scan-md.ts +37 -10
  172. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  173. package/src/checks/contracts.check.ts +0 -20
@@ -1,24 +1,94 @@
1
- // the shipkit window on macOS: its own app, so the dock and cmd-tab show the
2
- // house icon, never a second browser. One WKWebView per window on the home's
3
- // page; every `open -a <app> <url>` opens one more window, cmd-n too. A link
4
- // to another site opens in the default browser. Built once by `shipkit gui`
5
- // (home/gui-app.ts) with swiftc, no Xcode project.
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 homeFallback = "http://127.0.0.1:4799/"
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
- final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
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: arg ?? homeFallback)!)
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(URL(string: homeFallback)!) }
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
- let current = (NSApp.keyWindow?.contentView as? WKWebView)?.url
39
- open(current ?? URL(string: homeFallback)!)
110
+ open(keyPane()?.web.url ?? startURL)
40
111
  }
41
112
 
42
113
  @objc func reload(_ sender: Any?) {
43
- (NSApp.keyWindow?.contentView as? WKWebView)?.reload()
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? WKWebView)?.pageZoom = z }
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 = "shipkit"
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 repos apart
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! : "shipkit"
193
+ web.window?.title = (web.title?.isEmpty == false) ? web.title! : appName
90
194
  }
91
195
 
92
- // same host: stay in the window; anything else: the default browser
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
- let local = url.host == "127.0.0.1" || url.host == "localhost"
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 shipkit window for a local page
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.host == "127.0.0.1" || url.host == "localhost" { open(url) } else { NSWorkspace.shared.open(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 shipkit", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
115
- appMenu.addItem(withTitle: "Quit shipkit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
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)
@@ -1,90 +1,122 @@
1
- // the macOS app `shipkit gui` opens windows through: a bundle built once from
2
- // ShipkitGui.swift with swiftc, carrying the house repo's own icon, so the
3
- // dock and cmd-tab show it and never a second Chrome. Rebuilt only when the
4
- // source or the icon changed (a stamp in the bundle). No compiler, or not
5
- // macOS: the caller falls back to a Chrome app window.
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 house repo's own mark, largest first. Only a
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 = ['.shipkit/identity/icon.png', 'public/logo.png', 'public/favicon.png']
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
- export function infoPlist(o: { version: string }): string {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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>shipkit</string>
31
- \t<key>CFBundleDisplayName</key><string>shipkit</string>
32
- \t<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</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><dict><key>NSAllowsLocalNetworking</key><true/></dict>
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
- export function stampOf(source: string, icon: string | null): string {
54
- const h = new Bun.CryptoHasher('sha256').update(source)
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
- /** the app, built or rebuilt when stale. Throws with the step that failed.
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 ensureGuiApp(o: { root: string; version: string; app?: string; run?: Run }): { app: string; rebuilt: boolean } {
62
- const app = o.app ?? guiAppPath()
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 icon = appIconOf(o.root)
65
- const stamp = stampOf(readFileSync(SOURCE, 'utf8'), icon)
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({ version: o.version }))
74
- const built = run(['swiftc', '-O', '-o', join(tmp, 'Contents', 'MacOS', 'shipkit-gui'), SOURCE])
75
- if (built.code !== 0) { rmSync(tmp, { recursive: true, force: true }); throw new Error(`swiftc could not build the gui app: ${built.err.slice(0, 400)}`) }
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) { rmSync(tmp, { recursive: true, force: true }); throw new Error(`sips could not size the icon ${icon}: ${r.err.slice(0, 200)}`) }
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) { rmSync(tmp, { recursive: true, force: true }); throw new Error(`iconutil could not build the icon: ${r.err.slice(0, 200)}`) }
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
@@ -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
- type Launchctl = (args: string[]) => Promise<{ code: number; out: string }>
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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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' })
@@ -16,6 +16,9 @@ import { tailLines } from './supervise.ts'
16
16
  import { Hubs, type WorkerView } from './hubs.ts'
17
17
  import type { Planner, PlanRow } from '../plan/planner.ts'
18
18
  import type { Widgets, WidgetView } from './widgets.ts'
19
+ import { ActivityCache, windowMinutes } from './activity.ts'
20
+ import { HouseRuns } from '../serve/house-runs.ts'
21
+ import type { HouseView } from '../ledger/house-runs.ts'
19
22
 
20
23
  /** how often the daemon looks at every repo */
21
24
  export const TICK_MS = 2_000
@@ -76,10 +79,13 @@ export const runModernize = async (repo: WiredRepo, root: string, log: string):
76
79
  }
77
80
  }
78
81
 
79
- /** active = an agent works in it, VS Code has it open, or it is flagged by hand */
80
- export function activeSet(rows: ProjectRow[], flagged: string[]): Set<string> {
82
+ /** active = an agent works in it, VS Code has it open, or it is flagged by
83
+ * hand. The house repo is active whenever any repo is: its checks serve
84
+ * every repo (a login, a shared key), so they follow the machine's work */
85
+ export function activeSet(rows: ProjectRow[], flagged: string[], house?: string): Set<string> {
81
86
  const out = new Set(flagged)
82
87
  for (const r of rows) if ((r.sessions != null && r.sessions > 0) || r.vscode === true) out.add(r.name)
88
+ if (house != null && out.size > 0) out.add(house)
83
89
  return out
84
90
  }
85
91
 
@@ -87,13 +93,24 @@ export function activeSet(rows: ProjectRow[], flagged: string[]): Set<string> {
87
93
  export type WidgetsAnswer = { widgets: WidgetView[]; errors: string[] }
88
94
 
89
95
  /** the planned page's data: every row of the machine's plan */
90
- export type PlannedAnswer = { now: number; rows: PlanRow[]; errors: { repo: string; error: string }[]; running: boolean }
96
+ export type PlannedAnswer = {
97
+ now: number
98
+ rows: PlanRow[]
99
+ errors: { repo: string; error: string }[]
100
+ running: boolean
101
+ /** what holds work back, one line each (Planner.held) */
102
+ held: string[]
103
+ }
91
104
 
92
105
  export function homeRoutes(d: HomeDeps) {
93
106
  let repos: WiredRepo[] = []
94
107
  let rows: ProjectRow[] = []
95
108
  const modernizing = new Map<string, ModernizeView & { log: string }>()
109
+ // read only while the activity page asks, never on the tick
110
+ const activity = new ActivityCache({ declared: (name) => d.plan?.declared(name) ?? null, titles: (name) => d.plan?.titles(name) ?? null })
96
111
  const modernizeLog = (repo: string): string => join(d.dir, 'modernize', `${repo}.log`)
112
+ // the dock: every loaded repo's recent runs, read only while a page asks
113
+ const house = new HouseRuns(() => repos)
97
114
 
98
115
  async function refresh(): Promise<void> {
99
116
  const a = await d.gutter.answer()
@@ -107,7 +124,7 @@ export function homeRoutes(d: HomeDeps) {
107
124
 
108
125
  async function tick(): Promise<void> {
109
126
  await refresh()
110
- active = activeSet(rows, d.state.data.active)
127
+ active = activeSet(rows, d.state.data.active, rows.find((r) => r.path === d.root)?.name ?? d.name)
111
128
  // the plan's clock: one look per tick, and it starts at most one batch
112
129
  d.plan?.tick()
113
130
  // a widget render never holds the tick back
@@ -148,11 +165,22 @@ export function homeRoutes(d: HomeDeps) {
148
165
  }
149
166
  if (path === '/api/home/planned.json') {
150
167
  if (repos.length === 0) await refresh()
151
- const out: PlannedAnswer = { now: Date.now(), rows: d.plan?.rows() ?? [], errors: d.plan?.errors() ?? [], running: d.plan?.busy() === true }
168
+ const out: PlannedAnswer = { now: Date.now(), rows: d.plan?.rows() ?? [], errors: d.plan?.errors() ?? [], running: d.plan?.busy() === true, held: d.plan?.held() ?? [] }
152
169
  return Response.json(out)
153
170
  }
171
+ if (path === '/api/home/activity.json') {
172
+ if (repos.length === 0) await refresh()
173
+ const q = new URL(req.url).searchParams
174
+ return Response.json(await activity.get(repos, windowMinutes(q.get('m')), { fresh: q.get('fresh') === '1' }))
175
+ }
176
+ if (path === '/api/house-runs.json') {
177
+ if (repos.length === 0) await refresh()
178
+ const view: HouseView = { ...house.view(), held: d.plan?.held() ?? [] }
179
+ return Response.json(view, { headers: { 'cache-control': 'no-store' } })
180
+ }
154
181
  if (path === '/api/home/widgets.json') {
155
- const out: WidgetsAnswer = { widgets: d.widgets?.views() ?? [], errors: d.widgets?.errors ?? [] }
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 ?? [] }
156
184
  return Response.json(out)
157
185
  }
158
186
  if (path === '/api/home.json') {
@@ -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
- export const CHROME = process.env.CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
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 ?? houseRootOf(process.cwd()) ?? process.cwd(), version: o.version ?? '0' })
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(['osascript', '-e', `quit app id "${BUNDLE_ID}"`], { stdout: 'ignore', stderr: 'ignore' })
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