fix(macos): validate embedded CLI helper
This commit is contained in:
@@ -105,8 +105,10 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
|
private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
|
||||||
private let devLinkCommand =
|
private var devLinkCommand: String {
|
||||||
"ln -sf /Applications/Clawdis.app/Contents/Resources/Relay/clawdis /usr/local/bin/clawdis"
|
let bundlePath = Bundle.main.bundlePath
|
||||||
|
return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdis' /usr/local/bin/clawdis"
|
||||||
|
}
|
||||||
|
|
||||||
private struct LocalGatewayProbe: Equatable {
|
private struct LocalGatewayProbe: Equatable {
|
||||||
let port: Int
|
let port: Int
|
||||||
|
|||||||
@@ -133,18 +133,38 @@ func age(from date: Date, now: Date = .init()) -> String {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum CLIInstaller {
|
enum CLIInstaller {
|
||||||
static func installedLocation() -> String? {
|
private static func embeddedHelperURL() -> URL {
|
||||||
let fm = FileManager.default
|
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
|
||||||
|
}
|
||||||
|
|
||||||
for basePath in cliHelperSearchPaths {
|
static func installedLocation() -> String? {
|
||||||
|
self.installedLocation(
|
||||||
|
searchPaths: cliHelperSearchPaths,
|
||||||
|
embeddedHelper: self.embeddedHelperURL(),
|
||||||
|
fileManager: .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func installedLocation(
|
||||||
|
searchPaths: [String],
|
||||||
|
embeddedHelper: URL,
|
||||||
|
fileManager: FileManager) -> String?
|
||||||
|
{
|
||||||
|
let embedded = embeddedHelper.resolvingSymlinksInPath()
|
||||||
|
|
||||||
|
for basePath in searchPaths {
|
||||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path
|
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path
|
||||||
var isDirectory: ObjCBool = false
|
var isDirectory: ObjCBool = false
|
||||||
|
|
||||||
guard fm.fileExists(atPath: candidate, isDirectory: &isDirectory), !isDirectory.boolValue else {
|
guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
|
||||||
|
!isDirectory.boolValue
|
||||||
|
else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if fm.isExecutableFile(atPath: candidate) {
|
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
||||||
|
|
||||||
|
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
|
||||||
|
if resolved == embedded {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,9 +177,11 @@ enum CLIInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||||
let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
|
let helper = self.embeddedHelperURL()
|
||||||
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
||||||
await statusHandler("Helper missing in bundle; rebuild via scripts/package-mac-app.sh")
|
await statusHandler(
|
||||||
|
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
|
||||||
|
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
apps/macos/Tests/ClawdisIPCTests/CLIInstallerTests.swift
Normal file
43
apps/macos/Tests/ClawdisIPCTests/CLIInstallerTests.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
|
@MainActor
|
||||||
|
struct CLIInstallerTests {
|
||||||
|
@Test func installedLocationOnlyAcceptsEmbeddedHelper() throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let root = fm.temporaryDirectory.appendingPathComponent(
|
||||||
|
"clawdis-cli-installer-\(UUID().uuidString)")
|
||||||
|
defer { try? fm.removeItem(at: root) }
|
||||||
|
|
||||||
|
let embedded = root.appendingPathComponent("Relay/clawdis")
|
||||||
|
try fm.createDirectory(at: embedded.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
fm.createFile(atPath: embedded.path, contents: Data())
|
||||||
|
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: embedded.path)
|
||||||
|
|
||||||
|
let binDir = root.appendingPathComponent("bin")
|
||||||
|
try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
|
||||||
|
let link = binDir.appendingPathComponent("clawdis")
|
||||||
|
try fm.createSymbolicLink(at: link, withDestinationURL: embedded)
|
||||||
|
|
||||||
|
let found = CLIInstaller.installedLocation(
|
||||||
|
searchPaths: [binDir.path],
|
||||||
|
embeddedHelper: embedded,
|
||||||
|
fileManager: fm)
|
||||||
|
#expect(found == link.path)
|
||||||
|
|
||||||
|
try fm.removeItem(at: link)
|
||||||
|
let other = root.appendingPathComponent("Other/clawdis")
|
||||||
|
try fm.createDirectory(at: other.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
fm.createFile(atPath: other.path, contents: Data())
|
||||||
|
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: other.path)
|
||||||
|
try fm.createSymbolicLink(at: link, withDestinationURL: other)
|
||||||
|
|
||||||
|
let rejected = CLIInstaller.installedLocation(
|
||||||
|
searchPaths: [binDir.path],
|
||||||
|
embeddedHelper: embedded,
|
||||||
|
fileManager: fm)
|
||||||
|
#expect(rejected == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdis
|
@testable import Clawdis
|
||||||
|
|
||||||
@Suite struct GatewayDiscoveryModelTests {
|
@Suite
|
||||||
|
@MainActor
|
||||||
|
struct GatewayDiscoveryModelTests {
|
||||||
@Test func localGatewayMatchesLanHost() {
|
@Test func localGatewayMatchesLanHost() {
|
||||||
let local = GatewayDiscoveryModel.LocalIdentity(
|
let local = GatewayDiscoveryModel.LocalIdentity(
|
||||||
hostTokens: ["studio"],
|
hostTokens: ["studio"],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import Testing
|
|||||||
server: [:],
|
server: [:],
|
||||||
features: [:],
|
features: [:],
|
||||||
snapshot: snapshot,
|
snapshot: snapshot,
|
||||||
|
canvashosturl: nil,
|
||||||
policy: [:])
|
policy: [:])
|
||||||
|
|
||||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
|
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
|
||||||
|
|||||||
@@ -6,41 +6,56 @@ import Testing
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct MasterDiscoveryMenuSmokeTests {
|
struct MasterDiscoveryMenuSmokeTests {
|
||||||
@Test func inlineListBuildsBodyWhenEmpty() {
|
@Test func inlineListBuildsBodyWhenEmpty() {
|
||||||
let discovery = MasterDiscoveryModel()
|
let discovery = GatewayDiscoveryModel()
|
||||||
discovery.statusText = "Searching…"
|
discovery.statusText = "Searching…"
|
||||||
discovery.masters = []
|
discovery.gateways = []
|
||||||
|
|
||||||
let view = MasterDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in })
|
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func inlineListBuildsBodyWithMasterAndSelection() {
|
@Test func inlineListBuildsBodyWithMasterAndSelection() {
|
||||||
let discovery = MasterDiscoveryModel()
|
let discovery = GatewayDiscoveryModel()
|
||||||
discovery.statusText = "Found 1"
|
discovery.statusText = "Found 1"
|
||||||
discovery.masters = [
|
discovery.gateways = [
|
||||||
.init(
|
GatewayDiscoveryModel.DiscoveredGateway(
|
||||||
displayName: "Office Mac",
|
displayName: "Office Mac",
|
||||||
lanHost: "office.local",
|
lanHost: "office.local",
|
||||||
tailnetDns: "office.tailnet-123.ts.net",
|
tailnetDns: "office.tailnet-123.ts.net",
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
debugID: "office"),
|
stableID: "office",
|
||||||
|
debugID: "office",
|
||||||
|
isLocal: false),
|
||||||
]
|
]
|
||||||
|
|
||||||
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
|
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
|
||||||
let view = MasterDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in })
|
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func menuBuildsBodyWithMasters() {
|
@Test func menuBuildsBodyWithMasters() {
|
||||||
let discovery = MasterDiscoveryModel()
|
let discovery = GatewayDiscoveryModel()
|
||||||
discovery.statusText = "Found 2"
|
discovery.statusText = "Found 2"
|
||||||
discovery.masters = [
|
discovery.gateways = [
|
||||||
.init(displayName: "A", lanHost: "a.local", tailnetDns: nil, sshPort: 22, debugID: "a"),
|
GatewayDiscoveryModel.DiscoveredGateway(
|
||||||
.init(displayName: "B", lanHost: nil, tailnetDns: "b.ts.net", sshPort: 22, debugID: "b"),
|
displayName: "A",
|
||||||
|
lanHost: "a.local",
|
||||||
|
tailnetDns: nil,
|
||||||
|
sshPort: 22,
|
||||||
|
stableID: "a",
|
||||||
|
debugID: "a",
|
||||||
|
isLocal: false),
|
||||||
|
GatewayDiscoveryModel.DiscoveredGateway(
|
||||||
|
displayName: "B",
|
||||||
|
lanHost: nil,
|
||||||
|
tailnetDns: "b.ts.net",
|
||||||
|
sshPort: 22,
|
||||||
|
stableID: "b",
|
||||||
|
debugID: "b",
|
||||||
|
isLocal: false),
|
||||||
]
|
]
|
||||||
|
|
||||||
let view = MasterDiscoveryMenu(discovery: discovery, onSelect: { _ in })
|
let view = GatewayDiscoveryMenu(discovery: discovery, onSelect: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct OnboardingViewSmokeTests {
|
|||||||
let view = OnboardingView(
|
let view = OnboardingView(
|
||||||
state: state,
|
state: state,
|
||||||
permissionMonitor: PermissionMonitor.shared,
|
permissionMonitor: PermissionMonitor.shared,
|
||||||
discoveryModel: MasterDiscoveryModel())
|
discoveryModel: GatewayDiscoveryModel())
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ run_step "bundle canvas a2ui" bash -lc "cd '${ROOT_DIR}' && pnpm canvas:a2ui:bun
|
|||||||
run_step "clean build cache" bash -lc "cd '${ROOT_DIR}/apps/macos' && rm -rf .build .build-swift .swiftpm 2>/dev/null || true"
|
run_step "clean build cache" bash -lc "cd '${ROOT_DIR}/apps/macos' && rm -rf .build .build-swift .swiftpm 2>/dev/null || true"
|
||||||
run_step "swift build" bash -lc "cd '${ROOT_DIR}/apps/macos' && swift build -q --product Clawdis"
|
run_step "swift build" bash -lc "cd '${ROOT_DIR}/apps/macos' && swift build -q --product Clawdis"
|
||||||
|
|
||||||
# 3) Package app (skip TS + gateway staging; rely on global/custom install for gateway JS).
|
# 3) Package app (default to bundling the embedded gateway + CLI).
|
||||||
run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=1 SKIP_GATEWAY_PACKAGE=1 '${ROOT_DIR}/scripts/package-mac-app.sh'"
|
run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=${SKIP_TSC:-1} SKIP_GATEWAY_PACKAGE=${SKIP_GATEWAY_PACKAGE:-0} '${ROOT_DIR}/scripts/package-mac-app.sh'"
|
||||||
|
|
||||||
choose_app_bundle() {
|
choose_app_bundle() {
|
||||||
if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then
|
if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user