mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
fix(bar): preserve Gatekeeper quarantine on install (#1534)
Preserves the Gatekeeper quarantine attribute on the installed CCS Bar app so the user makes the right-click to Open trust decision, instead of silently clearing it.
This commit is contained in:
1 parent
e3566ed765
commit
c776e18434
4 files changed
+29
-263
No files matched your search
+4
-8
@@ -35,7 +35,7 @@ If `CCS Bar.app` is already installed, the command shows the current version and
|
|||||||
|
|
||||||
After installation, CCS reads the app version directly from the bundle's `Info.plist` and pins it to `~/.ccs/bar/.version`. It then performs a reachability check against the bar API (`GET /api/bar/summary`). A 404 response means the running CCS server predates CCS Bar support — update CCS to a version that includes CCS Bar, then restart `ccs bar`.
|
After installation, CCS reads the app version directly from the bundle's `Info.plist` and pins it to `~/.ccs/bar/.version`. It then performs a reachability check against the bar API (`GET /api/bar/summary`). A 404 response means the running CCS server predates CCS Bar support — update CCS to a version that includes CCS Bar, then restart `ccs bar`.
|
||||||
|
|
||||||
After a successful install, CCS asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely:
|
After a successful install, CCS leaves the macOS Gatekeeper quarantine marker in place so macOS can perform its normal first-launch verification. CCS then asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ccs bar install --launch # install and launch immediately
|
ccs bar install --launch # install and launch immediately
|
||||||
@@ -44,13 +44,9 @@ ccs bar install --no-launch # install, skip launch prompt
|
|||||||
|
|
||||||
### Gatekeeper note
|
### Gatekeeper note
|
||||||
|
|
||||||
The install command automatically clears the macOS Gatekeeper quarantine attribute (`xattr -dr com.apple.quarantine`) on the downloaded app. If clearing fails for any reason, the command falls back to printing the manual command:
|
The install command does not automatically clear the macOS Gatekeeper quarantine attribute on the downloaded app. This preserves macOS first-launch verification for the floating release download.
|
||||||
|
|
||||||
```bash
|
If macOS blocks the app on first launch, make an explicit trust decision by right-clicking the app and choosing Open.
|
||||||
xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app"
|
|
||||||
```
|
|
||||||
|
|
||||||
Or right-click the app and choose Open.
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -109,7 +105,7 @@ This removes `~/Applications/CCS Bar.app` and the installed version pin. It is a
|
|||||||
|
|
||||||
- Install fails with "server predates CCS Bar" or bar API returns 404: the CCS server running does not yet include CCS Bar. Update CCS (`npm i -g ccs@latest` or equivalent), then restart `ccs bar`.
|
- Install fails with "server predates CCS Bar" or bar API returns 404: the CCS server running does not yet include CCS Bar. Update CCS (`npm i -g ccs@latest` or equivalent), then restart `ccs bar`.
|
||||||
- Server failed to start: `ccs bar` first checks whether a CCS server is already running on the candidate ports (3000, 3001, 3002, 8000, 8080) and reuses it if found. A true failure here means a non-CCS process is occupying all candidate ports. Free one of those ports and re-run `ccs bar`. Check `~/.ccs/bar/serve.log` for the background server's output.
|
- Server failed to start: `ccs bar` first checks whether a CCS server is already running on the candidate ports (3000, 3001, 3002, 8000, 8080) and reuses it if found. A true failure here means a non-CCS process is occupying all candidate ports. Free one of those ports and re-run `ccs bar`. Check `~/.ccs/bar/serve.log` for the background server's output.
|
||||||
- App won't open (Gatekeeper): right-click and Open, or clear quarantine with the `xattr` command above.
|
- App won't open (Gatekeeper): right-click the app and choose Open to make an explicit trust decision.
|
||||||
- Menu shows "CCS is not running": open the menu again to let the app start the server, or run `ccs bar status` to check and `ccs bar` to start it.
|
- Menu shows "CCS is not running": open the menu again to let the app start the server, or run `ccs bar status` to check and `ccs bar` to start it.
|
||||||
- Quota not updating: re-open the menu to force a refresh, or confirm the server is still reachable on loopback.
|
- Quota not updating: re-open the menu to force a refresh, or confirm the server is still reachable on loopback.
|
||||||
|
|
||||||
|
|||||||
@@ -60,15 +60,13 @@ export async function showHelp(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(dim(' macOS only. The app communicates with the CCS web-server on localhost only.'));
|
console.log(dim(' macOS only. The app communicates with the CCS web-server on localhost only.'));
|
||||||
|
console.log(dim(' Gatekeeper quarantine is kept in place for macOS first-launch verification.'));
|
||||||
console.log(
|
console.log(
|
||||||
dim(' `ccs bar launch` spawns the server detached — the terminal is freed immediately.')
|
dim(' `ccs bar launch` spawns the server detached — the terminal is freed immediately.')
|
||||||
);
|
);
|
||||||
console.log(dim(' The server persists until stopped with `ccs bar stop` or system reboot.'));
|
console.log(dim(' The server persists until stopped with `ccs bar stop` or system reboot.'));
|
||||||
console.log(
|
console.log(
|
||||||
dim(
|
dim(' If macOS blocks the app, right-click > Open to make an explicit trust decision.')
|
||||||
' Gatekeeper quarantine is cleared automatically after install. If the app is still blocked,'
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
console.log(dim(' right-click > Open or run `xattr -dr com.apple.quarantine` manually.'));
|
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
@@ -85,13 +85,6 @@ export interface InstallDeps {
|
|||||||
getCcsDir: () => string;
|
getCcsDir: () => string;
|
||||||
/** Destination directory for the .app bundle (~/Applications by default). */
|
/** Destination directory for the .app bundle (~/Applications by default). */
|
||||||
getAppsDir: () => string;
|
getAppsDir: () => string;
|
||||||
/**
|
|
||||||
* Clear the macOS Gatekeeper quarantine attribute from the installed app.
|
|
||||||
* Run via `/usr/bin/xattr -dr com.apple.quarantine <appPath>` (execFile, not shell-string).
|
|
||||||
* Returns true on success, false if xattr is unavailable or the call fails (non-fatal).
|
|
||||||
* Injectable for tests — avoids touching the real system.
|
|
||||||
*/
|
|
||||||
clearQuarantine: (appPath: string) => Promise<boolean>;
|
|
||||||
/**
|
/**
|
||||||
* Invoke handleBarLaunch after a successful install when the user consents.
|
* Invoke handleBarLaunch after a successful install when the user consents.
|
||||||
* Injectable so tests can assert invocation without starting a real server.
|
* Injectable so tests can assert invocation without starting a real server.
|
||||||
@@ -416,25 +409,6 @@ function defaultGetAppsDir(): string {
|
|||||||
return path.join(os.homedir(), 'Applications');
|
return path.join(os.homedir(), 'Applications');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the macOS Gatekeeper quarantine attribute from the installed app.
|
|
||||||
* Uses absolute path `/usr/bin/xattr` to avoid PATH hijacking.
|
|
||||||
* Runs `/usr/bin/xattr -dr com.apple.quarantine <appPath>` via execFile (not shell-string)
|
|
||||||
* so the path is passed as an argument, not interpolated into a shell command.
|
|
||||||
* Returns true on success, false on any error (non-fatal — install already succeeded).
|
|
||||||
*/
|
|
||||||
async function defaultClearQuarantine(appPath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const { execFile } = await import('child_process');
|
|
||||||
const { promisify } = await import('util');
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
await execFileAsync('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', appPath]);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultRemoveExistingApp(appPath: string): void {
|
function defaultRemoveExistingApp(appPath: string): void {
|
||||||
fs.rmSync(appPath, { recursive: true, force: true });
|
fs.rmSync(appPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -524,7 +498,6 @@ export async function handleBarInstall(
|
|||||||
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
|
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
|
||||||
const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat;
|
const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat;
|
||||||
const readAppBundleVersion = deps.readAppBundleVersion ?? defaultReadAppBundleVersion;
|
const readAppBundleVersion = deps.readAppBundleVersion ?? defaultReadAppBundleVersion;
|
||||||
const clearQuarantine = deps.clearQuarantine ?? defaultClearQuarantine;
|
|
||||||
const launchBar = deps.launchBar ?? defaultLaunchBar;
|
const launchBar = deps.launchBar ?? defaultLaunchBar;
|
||||||
const promptLaunch = deps.promptLaunch ?? defaultPromptLaunch;
|
const promptLaunch = deps.promptLaunch ?? defaultPromptLaunch;
|
||||||
const isBarRunning = deps.isBarRunning ?? defaultIsBarRunning;
|
const isBarRunning = deps.isBarRunning ?? defaultIsBarRunning;
|
||||||
@@ -678,13 +651,11 @@ export async function handleBarInstall(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
console.error(`[!] Could not write launch.json: ${msg}`);
|
console.error(`[!] Could not write launch.json: ${msg}`);
|
||||||
// Non-fatal — continue to quarantine clear + launch handoff.
|
// Non-fatal — continue to Gatekeeper note + launch handoff.
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Capability handshake via GET /api/bar/summary.
|
// 5. Capability handshake via GET /api/bar/summary.
|
||||||
// Runs BEFORE the quarantine-clear + launch handoff because it is a server-side
|
// This server-side check is unrelated to Gatekeeper.
|
||||||
// check unrelated to Gatekeeper. A failed quarantine clear must not prevent the
|
|
||||||
// compat result from being shown to the user.
|
|
||||||
// Read bar.json for baseUrl if present; otherwise fall back to localhost:3000.
|
// Read bar.json for baseUrl if present; otherwise fall back to localhost:3000.
|
||||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||||
let baseUrl = 'http://127.0.0.1:3000';
|
let baseUrl = 'http://127.0.0.1:3000';
|
||||||
@@ -715,22 +686,14 @@ export async function handleBarInstall(
|
|||||||
console.log('[i] Run `ccs bar` to start the server and recheck.');
|
console.log('[i] Run `ccs bar` to start the server and recheck.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Quarantine handling: run `xattr -dr com.apple.quarantine` automatically.
|
// 6. Gatekeeper handling: keep quarantine in place for downloaded apps.
|
||||||
// This clears the Gatekeeper quarantine flag that ad-hoc builds receive on download.
|
// CCS Bar is installed from a floating release asset and is not verified by a
|
||||||
// On success: print [OK] confirmation. On failure: fall back to printed guidance and
|
// pinned checksum/signature here, so do not strip macOS's quarantine marker.
|
||||||
// SKIP the launch handoff entirely — launching a still-quarantined app hits the
|
// If Gatekeeper blocks first launch, the user can make an explicit manual
|
||||||
// Gatekeeper block, so the user must clear quarantine manually first.
|
// trust decision outside the installer.
|
||||||
const quarantineCleared = await clearQuarantine(appPath);
|
console.log('[i] Gatekeeper note:');
|
||||||
if (quarantineCleared) {
|
console.log(' macOS may verify this downloaded app on first launch.');
|
||||||
console.log('[OK] Cleared Gatekeeper quarantine.');
|
console.log(' If macOS blocks it, right-click the app and select Open.');
|
||||||
} else {
|
|
||||||
console.log('[i] Gatekeeper note (ad-hoc build):');
|
|
||||||
console.log(' If macOS says the app is "damaged" or "unverified", run:');
|
|
||||||
console.log(` xattr -dr com.apple.quarantine "${appPath}"`);
|
|
||||||
console.log(' Or right-click the app and select Open.');
|
|
||||||
console.log('[i] After clearing quarantine, run `ccs bar` to launch.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Launch handoff.
|
// 7. Launch handoff.
|
||||||
// Already-running check: if CCS Bar is running after a (re)install, skip the
|
// Already-running check: if CCS Bar is running after a (re)install, skip the
|
||||||
|
|||||||
@@ -411,7 +411,6 @@ describe('bar install subcommand', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async (_baseUrl: string) => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async (_baseUrl: string) => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -437,7 +436,6 @@ describe('bar install subcommand', () => {
|
|||||||
},
|
},
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -459,7 +457,6 @@ describe('bar install subcommand', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
@@ -485,7 +482,6 @@ describe('bar install subcommand', () => {
|
|||||||
return { compatible: true, reason: 'ok' };
|
return { compatible: true, reason: 'ok' };
|
||||||
},
|
},
|
||||||
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -505,8 +501,7 @@ describe('bar install subcommand', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
||||||
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
clearQuarantine: async () => true,
|
isBarRunning: async () => false,
|
||||||
isBarRunning: async () => false,
|
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
@@ -521,14 +516,12 @@ describe('bar install subcommand', () => {
|
|||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
// Inject clearQuarantine returning false so the fallback xattr guidance is
|
// Gatekeeper quarantine is preserved (not cleared) — the note is always printed.
|
||||||
// always printed regardless of host platform (/usr/bin/xattr availability).
|
|
||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
clearQuarantine: async () => false,
|
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -597,7 +590,6 @@ describe('bar install: redirect-following download (#8)', () => {
|
|||||||
downloadAndExtract: redirectFollowingExtract,
|
downloadAndExtract: redirectFollowingExtract,
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -769,7 +761,6 @@ describe('bar install: compat capability handshake', () => {
|
|||||||
return { compatible: true, reason: 'ok' };
|
return { compatible: true, reason: 'ok' };
|
||||||
},
|
},
|
||||||
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -790,7 +781,6 @@ describe('bar install: compat capability handshake', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -812,7 +802,6 @@ describe('bar install: compat capability handshake', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
||||||
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -835,7 +824,6 @@ describe('bar install: compat capability handshake', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: false, reason: 'unreachable' }),
|
verifyCompat: async () => ({ compatible: false, reason: 'unreachable' }),
|
||||||
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -860,8 +848,7 @@ describe('bar install: compat capability handshake', () => {
|
|||||||
throw new Error('network explosion');
|
throw new Error('network explosion');
|
||||||
},
|
},
|
||||||
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
clearQuarantine: async () => true,
|
isBarRunning: async () => false,
|
||||||
isBarRunning: async () => false,
|
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
@@ -892,7 +879,6 @@ describe('bar install: post-extract app-exists assertion (#12)', () => {
|
|||||||
},
|
},
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -978,7 +964,6 @@ describe('bar install: Info.plist version extraction regression tests', () => {
|
|||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
// readAppBundleVersion returns the real version from Info.plist
|
// readAppBundleVersion returns the real version from Info.plist
|
||||||
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -1003,7 +988,6 @@ describe('bar install: Info.plist version extraction regression tests', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
@@ -1030,7 +1014,6 @@ describe('bar install: Info.plist version extraction regression tests', () => {
|
|||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
// Unreadable Info.plist — returns null
|
// Unreadable Info.plist — returns null
|
||||||
readAppBundleVersion: (_appPath: string) => null,
|
readAppBundleVersion: (_appPath: string) => null,
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
@@ -1109,7 +1092,6 @@ describe('bar install: Info.plist version extraction regression tests', () => {
|
|||||||
},
|
},
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
// readAppBundleVersion intentionally omitted → uses production default
|
// readAppBundleVersion intentionally omitted → uses production default
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
@@ -1147,7 +1129,6 @@ describe('bar install: Info.plist version extraction regression tests', () => {
|
|||||||
},
|
},
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
// readAppBundleVersion omitted → uses production default
|
// readAppBundleVersion omitted → uses production default
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
@@ -1487,7 +1468,6 @@ describe('bar install: zip-slip guard (fix #14)', () => {
|
|||||||
downloadAndExtract: safeExtract,
|
downloadAndExtract: safeExtract,
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
@@ -1531,7 +1511,6 @@ describe('bar install: stale version-pin removal on null plist read (Fix 1)', ()
|
|||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
// Simulate unreadable Info.plist
|
// Simulate unreadable Info.plist
|
||||||
readAppBundleVersion: (_appPath: string) => null,
|
readAppBundleVersion: (_appPath: string) => null,
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
@@ -2028,7 +2007,6 @@ describe('bar install: already-installed detection (GH-1504)', () => {
|
|||||||
if (fs.existsSync(appPath)) return '1.0.0';
|
if (fs.existsSync(appPath)) return '1.0.0';
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
clearQuarantine: async () => true,
|
|
||||||
launchBar: async () => {
|
launchBar: async () => {
|
||||||
calls.push('launch');
|
calls.push('launch');
|
||||||
},
|
},
|
||||||
@@ -2056,7 +2034,6 @@ describe('bar install: already-installed detection (GH-1504)', () => {
|
|||||||
},
|
},
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: () => null,
|
readAppBundleVersion: () => null,
|
||||||
clearQuarantine: async () => true,
|
|
||||||
launchBar: async () => {
|
launchBar: async () => {
|
||||||
calls.push('launch');
|
calls.push('launch');
|
||||||
},
|
},
|
||||||
@@ -2082,7 +2059,6 @@ describe('bar install: already-installed detection (GH-1504)', () => {
|
|||||||
},
|
},
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: () => '2.0.0',
|
readAppBundleVersion: () => '2.0.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
launchBar: async () => {
|
launchBar: async () => {
|
||||||
calls.push('launch');
|
calls.push('launch');
|
||||||
},
|
},
|
||||||
@@ -2110,9 +2086,8 @@ describe('bar install: quarantine handling (GH-1504)', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it('calls clearQuarantine with the correct app path after successful extraction', async () => {
|
it('does not clear quarantine automatically after successful extraction', async () => {
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
const quarantineCalls: string[] = [];
|
|
||||||
|
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
@@ -2121,10 +2096,6 @@ describe('bar install: quarantine handling (GH-1504)', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: () => '1.0.0',
|
readAppBundleVersion: () => '1.0.0',
|
||||||
clearQuarantine: async (appPath: string) => {
|
|
||||||
quarantineCalls.push(appPath);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
launchBar: async () => {
|
launchBar: async () => {
|
||||||
/* noop */
|
/* noop */
|
||||||
},
|
},
|
||||||
@@ -2133,59 +2104,15 @@ describe('bar install: quarantine handling (GH-1504)', () => {
|
|||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(quarantineCalls).toHaveLength(1);
|
|
||||||
expect(quarantineCalls[0]).toMatch(/CCS Bar\.app/);
|
|
||||||
expect(quarantineCalls[0]).toBe(path.join(appsDir, 'CCS Bar.app'));
|
|
||||||
|
|
||||||
const allOutput = consoleOutput.join('\n');
|
const allOutput = consoleOutput.join('\n');
|
||||||
expect(allOutput).toMatch(/\[OK\].*[Cc]leared.*[Qq]uarantine/);
|
expect(allOutput).toMatch(/Gatekeeper note/i);
|
||||||
|
expect(allOutput).toMatch(/right-click.*Open/i);
|
||||||
|
expect(allOutput).not.toMatch(/Cleared Gatekeeper quarantine/i);
|
||||||
|
expect(allOutput).not.toMatch(/xattr.*quarantine/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('quarantine failure is non-fatal: falls back to printed xattr hint; launch handoff skipped', async () => {
|
it('install exits early when extraction does not produce the expected app bundle', async () => {
|
||||||
// Launch Retry finding: when clearQuarantine fails, the entire launch handoff must be
|
|
||||||
// skipped (prompt and launchBar must NOT be called) and the follow-up hint must be printed.
|
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
let launchCalled = false;
|
|
||||||
let promptCalled = false;
|
|
||||||
|
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
|
||||||
|
|
||||||
await handleBarInstall([], {
|
|
||||||
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
||||||
readAppBundleVersion: () => '1.0.0',
|
|
||||||
clearQuarantine: async () => false,
|
|
||||||
launchBar: async () => {
|
|
||||||
launchCalled = true;
|
|
||||||
},
|
|
||||||
promptLaunch: async () => {
|
|
||||||
promptCalled = true;
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
||||||
getAppsDir: () => appsDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Install still reports success
|
|
||||||
const allOutput = consoleOutput.join('\n');
|
|
||||||
expect(allOutput).toMatch(/\[OK\].*CCS Bar/);
|
|
||||||
expect(allOutput).not.toMatch(/\[X\]/);
|
|
||||||
|
|
||||||
// Fallback xattr hint printed
|
|
||||||
expect(allOutput).toMatch(/xattr.*quarantine/i);
|
|
||||||
|
|
||||||
// Launch Retry finding: prompt and launch must both be skipped
|
|
||||||
expect(promptCalled).toBe(false);
|
|
||||||
expect(launchCalled).toBe(false);
|
|
||||||
|
|
||||||
// Follow-up hint must guide the user to run `ccs bar` after manual clear
|
|
||||||
expect(allOutput).toMatch(/After clearing quarantine.*ccs bar/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clearQuarantine is NOT called when extraction fails (app path absent)', async () => {
|
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
|
||||||
const quarantineCalls: string[] = [];
|
|
||||||
|
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
@@ -2197,10 +2124,6 @@ describe('bar install: quarantine handling (GH-1504)', () => {
|
|||||||
},
|
},
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
readAppBundleVersion: () => '1.0.0',
|
readAppBundleVersion: () => '1.0.0',
|
||||||
clearQuarantine: async (appPath: string) => {
|
|
||||||
quarantineCalls.push(appPath);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
launchBar: async () => {
|
launchBar: async () => {
|
||||||
/* noop */
|
/* noop */
|
||||||
},
|
},
|
||||||
@@ -2210,17 +2133,16 @@ describe('bar install: quarantine handling (GH-1504)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should have returned early due to missing app
|
// Should have returned early due to missing app
|
||||||
expect(quarantineCalls).toHaveLength(0);
|
|
||||||
const allOutput = consoleOutput.join('\n');
|
const allOutput = consoleOutput.join('\n');
|
||||||
expect(allOutput).toMatch(/\[X\]/);
|
expect(allOutput).toMatch(/\[X\]/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Launch Retry finding — quarantine failure blocks entire launch handoff
|
// Gatekeeper preservation — quarantine is not cleared before launch handoff
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('bar install: launch retry finding — quarantine failure skips launch handoff', () => {
|
describe('bar install: Gatekeeper quarantine preservation', () => {
|
||||||
const FAKE_DOWNLOAD_URL =
|
const FAKE_DOWNLOAD_URL =
|
||||||
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
||||||
|
|
||||||
@@ -2243,7 +2165,7 @@ describe('bar install: launch retry finding — quarantine failure skips launch
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it('clearQuarantine false: promptLaunch is NOT called', async () => {
|
it('promptLaunch is still called after install (quarantine preserved, not cleared)', async () => {
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
let promptCalled = false;
|
let promptCalled = false;
|
||||||
|
|
||||||
@@ -2251,66 +2173,6 @@ describe('bar install: launch retry finding — quarantine failure skips launch
|
|||||||
|
|
||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
...baseDeps(appsDir),
|
...baseDeps(appsDir),
|
||||||
clearQuarantine: async () => false,
|
|
||||||
launchBar: async () => {
|
|
||||||
/* noop */
|
|
||||||
},
|
|
||||||
promptLaunch: async () => {
|
|
||||||
promptCalled = true;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(promptCalled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clearQuarantine false + --launch: launchBar is NOT called (explicit flag cannot override failed clear)', async () => {
|
|
||||||
// --launch does not bypass a failed quarantine clear — Gatekeeper would still block.
|
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
|
||||||
let launchCalled = false;
|
|
||||||
|
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
|
||||||
|
|
||||||
await handleBarInstall(['--launch'], {
|
|
||||||
...baseDeps(appsDir),
|
|
||||||
clearQuarantine: async () => false,
|
|
||||||
launchBar: async () => {
|
|
||||||
launchCalled = true;
|
|
||||||
},
|
|
||||||
promptLaunch: async () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(launchCalled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clearQuarantine false: follow-up hint printed directing user to run `ccs bar` after manual clear', async () => {
|
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
|
||||||
|
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
|
||||||
|
|
||||||
await handleBarInstall([], {
|
|
||||||
...baseDeps(appsDir),
|
|
||||||
clearQuarantine: async () => false,
|
|
||||||
launchBar: async () => {
|
|
||||||
/* noop */
|
|
||||||
},
|
|
||||||
promptLaunch: async () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allOutput = consoleOutput.join('\n');
|
|
||||||
expect(allOutput).toMatch(/After clearing quarantine.*ccs bar/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clearQuarantine true: launch handoff proceeds normally (prompt called)', async () => {
|
|
||||||
// Successful clear must not change existing behavior — prompt is still called.
|
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
|
||||||
let promptCalled = false;
|
|
||||||
|
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
|
||||||
|
|
||||||
await handleBarInstall([], {
|
|
||||||
...baseDeps(appsDir),
|
|
||||||
clearQuarantine: async () => true,
|
|
||||||
launchBar: async () => {
|
launchBar: async () => {
|
||||||
/* noop */
|
/* noop */
|
||||||
},
|
},
|
||||||
@@ -2323,7 +2185,7 @@ describe('bar install: launch retry finding — quarantine failure skips launch
|
|||||||
expect(promptCalled).toBe(true);
|
expect(promptCalled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clearQuarantine true + --launch: launchBar invoked as before', async () => {
|
it('--launch invokes launchBar', async () => {
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
let launchCalled = false;
|
let launchCalled = false;
|
||||||
|
|
||||||
@@ -2331,7 +2193,6 @@ describe('bar install: launch retry finding — quarantine failure skips launch
|
|||||||
|
|
||||||
await handleBarInstall(['--launch'], {
|
await handleBarInstall(['--launch'], {
|
||||||
...baseDeps(appsDir),
|
...baseDeps(appsDir),
|
||||||
clearQuarantine: async () => true,
|
|
||||||
launchBar: async () => {
|
launchBar: async () => {
|
||||||
launchCalled = true;
|
launchCalled = true;
|
||||||
},
|
},
|
||||||
@@ -2362,7 +2223,6 @@ describe('bar install: launch flags and prompt (GH-1504)', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
||||||
readAppBundleVersion: () => '1.0.0',
|
readAppBundleVersion: () => '1.0.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
// isBarRunning injected as false so tests are deterministic regardless of
|
// isBarRunning injected as false so tests are deterministic regardless of
|
||||||
// whether the real CCS Bar process is running on the test machine.
|
// whether the real CCS Bar process is running on the test machine.
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
@@ -2472,52 +2332,6 @@ describe('bar install: launch flags and prompt (GH-1504)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Finding 1 — PATH hijack: xattr must use absolute path /usr/bin/xattr
|
|
||||||
// (The default impl is internal; tested via injectable clearQuarantine seam.
|
|
||||||
// The production behavior is captured by the contract test below.)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('bar install: xattr absolute path contract (Finding 1)', () => {
|
|
||||||
const FAKE_DOWNLOAD_URL =
|
|
||||||
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
||||||
|
|
||||||
function fakeExtract(appsDir: string) {
|
|
||||||
return async (_url: string, dest: string) => {
|
|
||||||
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it('clearQuarantine injectable dep receives the correct app path (contract test)', async () => {
|
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
|
||||||
const quarantineArgs: string[] = [];
|
|
||||||
|
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
|
||||||
|
|
||||||
await handleBarInstall([], {
|
|
||||||
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
||||||
readAppBundleVersion: () => '1.0.0',
|
|
||||||
clearQuarantine: async (appPath: string) => {
|
|
||||||
quarantineArgs.push(appPath);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
launchBar: async () => {
|
|
||||||
/* noop */
|
|
||||||
},
|
|
||||||
promptLaunch: async () => false,
|
|
||||||
isBarRunning: async () => false,
|
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
||||||
getAppsDir: () => appsDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Production invokes clearQuarantine with the full app path — not a bare binary name.
|
|
||||||
expect(quarantineArgs).toHaveLength(1);
|
|
||||||
expect(quarantineArgs[0]).toBe(path.join(appsDir, 'CCS Bar.app'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Finding 2 — TTY gate: prompt gated on stdin.isTTY, not stdout.isTTY
|
// Finding 2 — TTY gate: prompt gated on stdin.isTTY, not stdout.isTTY
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -2538,7 +2352,6 @@ describe('bar install: stdin-TTY gate for launch prompt (Finding 2)', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
||||||
readAppBundleVersion: () => '1.0.0',
|
readAppBundleVersion: () => '1.0.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
@@ -2627,7 +2440,6 @@ describe('bar install: already-running detection (Finding 3)', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
||||||
readAppBundleVersion: () => '1.0.0',
|
readAppBundleVersion: () => '1.0.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
...extra,
|
...extra,
|
||||||
@@ -2792,7 +2604,6 @@ describe('bar install: whitespace-only CFBundleShortVersionString yields null (F
|
|||||||
},
|
},
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
// readAppBundleVersion intentionally omitted → uses production default
|
// readAppBundleVersion intentionally omitted → uses production default
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
@@ -2825,7 +2636,6 @@ describe('bar install: stage-then-swap safety (Data Loss finding)', () => {
|
|||||||
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
||||||
readAppBundleVersion: () => '2.0.0',
|
readAppBundleVersion: () => '2.0.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
launchBar: async () => {
|
launchBar: async () => {
|
||||||
/* noop */
|
/* noop */
|
||||||
@@ -3024,7 +2834,6 @@ describe('bar install: silent-decline fix — hint on user decline (review findi
|
|||||||
},
|
},
|
||||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
||||||
readAppBundleVersion: () => '1.0.0',
|
readAppBundleVersion: () => '1.0.0',
|
||||||
clearQuarantine: async () => true,
|
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
launchBar: async () => {
|
launchBar: async () => {
|
||||||
/* noop */
|
/* noop */
|
||||||
|
|||||||
Reference in new issue
Block a user