diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..24295cf --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Dev server url e.g. example.developer.shellular.dev +DEV_SERVER= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3246736..7fb5032 100644 --- a/.gitignore +++ b/.gitignore @@ -34,11 +34,18 @@ keystore.jks /build.json dev/terminals.config +# Apple build and signing artifacts *.xcarchive +*.ipa +*.p12 +*.p8 +*.mobileprovision +platforms/ios/Runner 20??-??-?? ??-??-??/ xcuserdata *.xcuserstate +.build *.xcconfig # Agents -.claude \ No newline at end of file +.claude diff --git a/README.md b/README.md index 784f339..a9015e2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Use your dev machine from your phone. Connect to your Mac, PC, or VPS and ship f ## Features - **Remote terminal** — xterm.js-based shell access over WebSocket -- **File browser & editor** — browse, edit, and diff files with CodeMirror 6 +- **File browser & editor** — browse, edit, and diff files with Monaco on desktop and CodeMirror 6 on mobile - **Project management** — Git integration for your remote projects - **AI agents** — chat and task execution via ACP - **System monitoring** — CPU, memory, and battery dashboards @@ -23,6 +23,7 @@ Use your dev machine from your phone. Connect to your Mac, PC, or VPS and ship f - Platform SDKs (for native builds): - Android: JDK 17+, Android SDK - iOS: Xcode 16+, CocoaPods + - macOS: Xcode with the macOS SDK ## Quick Start @@ -53,9 +54,49 @@ Successful app joins are shown as read-only host and device history from the Acc # Type-check and build for production pnpm build android pnpm build ios +pnpm build macos pnpm build browser ``` +### Signed macOS DMG + +The macOS build can create a Developer ID-signed, notarized, and stapled DMG for distribution outside the Mac App Store: + +```bash +pnpm build macos --package dmg +``` + +The completed package is written to `dist/Shellular-.dmg`. The command only publishes the final file after signing, notarization, stapling, and Gatekeeper validation all succeed. A normal `pnpm build macos` continues to create only `platforms/macos/shellular.xcarchive`. + +Before the first DMG build: + +1. In Xcode, sign in to the company Apple Developer account and install a valid **Developer ID Application** certificate. The certificate and its private key must both be available in the login Keychain. +2. Create the local signing configuration: + + ```bash + cp platforms/macos/Signing.xcconfig.example platforms/macos/Signing.xcconfig + ``` + +3. Set the company team ID and Keychain profile name in `Signing.xcconfig`: + + ```xcconfig + DEVELOPMENT_TEAM = COMPANY_TEAM_ID + SHELLULAR_NOTARY_PROFILE = shellular-notary + ``` + +4. Store the notarization credentials in the Keychain. Use an app-specific password for the Apple ID: + + ```bash + xcrun notarytool store-credentials "shellular-notary" \ + --apple-id "APPLE_ID" \ + --team-id "COMPANY_TEAM_ID" \ + --password "APP_SPECIFIC_PASSWORD" + ``` + +`Signing.xcconfig`, Apple credentials, certificates, and private keys are not committed to Git. The DMG build uses the existing non-sandboxed direct-distribution entitlements so local CLI integration remains available, while the normal Release configuration remains unchanged. + +If the build reports that no Developer ID identity exists, confirm that the certificate has not expired and that its private key appears beneath it in Keychain Access. If Keychain profile validation fails, run `notarytool store-credentials` again with the same profile name. When Apple rejects a submission, the build prints the notarization log and does not publish the failed DMG. Renew an expiring certificate through the company developer account, install it with its private key, and rerun the build; no source configuration should need to change when the team ID stays the same. + ### iOS code signing The iOS build archives with `xcodebuild`, which needs a development team. The project already references a `Config.xcconfig`, but that file is **gitignored** — so each machine creates it once at `platforms/ios/Config.xcconfig` with your signing settings: @@ -87,7 +128,7 @@ That's it — no Xcode setup needed; the project is already wired to read this f ## Tech Stack -React 19 / TypeScript / Webpack 5 / Tailwind CSS 4 / xterm.js 6 / CodeMirror 6 / Chart.js / Framer Motion / libsodium / Bio +React 19 / TypeScript / Webpack 5 / Tailwind CSS 4 / xterm.js 6 / Monaco Editor / CodeMirror 6 (mobile) / Chart.js / Framer Motion / libsodium / Bio ## License diff --git a/dev/biome.json b/dev/biome.json index 283e475..da25184 100644 --- a/dev/biome.json +++ b/dev/biome.json @@ -8,7 +8,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["*.js"] + "includes": ["**/*.js"] }, "formatter": { "enabled": true, diff --git a/dev/build-args.js b/dev/build-args.js new file mode 100644 index 0000000..44b014d --- /dev/null +++ b/dev/build-args.js @@ -0,0 +1,61 @@ +const SUPPORTED_PLATFORMS = new Set(["android", "ios", "macos", "browser"]); +const SUPPORTED_PACKAGES = new Map([["macos", new Set(["dmg"])]]); + +export function parseBuildArgs(args) { + let platform; + let packageType; + let packageOptionSeen = false; + + for (let index = 0; index < args.length; index += 1) { + const rawArgument = args[index]; + const argument = rawArgument.toLowerCase(); + + if (SUPPORTED_PLATFORMS.has(argument)) { + if (platform) { + throw new Error(`Multiple build platforms were provided: ${platform}, ${argument}`); + } + platform = argument; + continue; + } + + if (argument === "--package" || argument.startsWith("--package=")) { + if (packageOptionSeen) { + throw new Error("The --package option may only be provided once"); + } + packageOptionSeen = true; + + if (argument === "--package") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error("The --package option requires a value"); + } + packageType = value.toLowerCase(); + index += 1; + } else { + packageType = rawArgument.slice(rawArgument.indexOf("=") + 1).toLowerCase(); + if (!packageType) { + throw new Error("The --package option requires a value"); + } + } + continue; + } + + throw new Error(`Unknown build argument: ${rawArgument}`); + } + + if (!platform) { + throw new Error("Please specify a platform: android, ios, macos, or browser"); + } + + if (packageType) { + const supportedForPlatform = SUPPORTED_PACKAGES.get(platform); + if (!supportedForPlatform) { + throw new Error(`The --package option is not supported for ${platform}`); + } + if (!supportedForPlatform.has(packageType)) { + throw new Error(`Unsupported ${platform} package type: ${packageType}`); + } + } + + return { platform, packageType }; +} diff --git a/dev/build-args.test.js b/dev/build-args.test.js new file mode 100644 index 0000000..37888d5 --- /dev/null +++ b/dev/build-args.test.js @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { parseBuildArgs } from "./build-args.js"; + +describe("parseBuildArgs", () => { + it.each(["android", "ios", "macos", "browser"])("parses an archive-only %s build", (platform) => { + expect(parseBuildArgs([platform])).toEqual({ + platform, + packageType: undefined, + }); + }); + + it("accepts a separate DMG package value", () => { + expect(parseBuildArgs(["macos", "--package", "dmg"])).toEqual({ + platform: "macos", + packageType: "dmg", + }); + }); + + it("accepts an equals-form DMG package value case-insensitively", () => { + expect(parseBuildArgs(["MacOS", "--package=DMG"])).toEqual({ + platform: "macos", + packageType: "dmg", + }); + }); + + it.each([ + [[], "Please specify a platform"], + [["macos", "--package"], "requires a value"], + [["macos", "--package="], "requires a value"], + [["macos", "--package", "zip"], "Unsupported macos package type: zip"], + [["ios", "--package", "dmg"], "not supported for ios"], + [["macos", "--package", "dmg", "--package=dmg"], "only be provided once"], + [["macos", "ios"], "Multiple build platforms"], + [["macos", "--unknown"], "Unknown build argument"], + ])("rejects invalid arguments %j", (args, message) => { + expect(() => parseBuildArgs(args)).toThrow(message); + }); +}); diff --git a/dev/build.js b/dev/build.js index 1283e93..7c08561 100644 --- a/dev/build.js +++ b/dev/build.js @@ -1,17 +1,19 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { type } from "node:os"; +import { parseBuildArgs } from "./build-args.js"; import config from "./config.js"; -config("production"); - -const args = process.argv.slice(2); -const platform = args.find((arg) => /(android|ios|macos|browser)/i.test(arg)); - -if (!platform) { - console.error("Please specify a platform: android, ios, macos, or browser"); +let buildOptions; +try { + buildOptions = parseBuildArgs(process.argv.slice(2)); +} catch (error) { + console.error(error?.message || error); process.exit(1); } +const { platform, packageType } = buildOptions; +config("production"); + const { default: build } = await import(`./${platform}/build.js`); const RED = type() === "Windows_NT" ? "\x1b[31m" : "\x1b[91m"; @@ -20,22 +22,19 @@ const GREEN = type() === "Windows_NT" ? "\x1b[32m" : "\x1b[92m"; const YELLOW = type() === "Windows_NT" ? "\x1b[33m" : "\x1b[93m"; const NC = type() === "Windows_NT" ? "\x1b[0m" : "\x1b[39m"; -const buildCommand = `webpack --progress --mode production --env platform=${platform}`; - (async () => { try { - // run pnpm install console.log(`\n${YELLOW}pnpm install${NC}`); - execSync("pnpm install", { stdio: "inherit" }); + execFileSync("pnpm", ["install"], { stdio: "inherit" }); - console.log(`${YELLOW}${buildCommand}${NC}`); - execSync(buildCommand, { stdio: "inherit" }); + console.log(`${YELLOW}webpack --progress --mode production --env platform=${platform}${NC}`); + execFileSync("webpack", ["--progress", "--mode", "production", "--env", `platform=${platform}`], { stdio: "inherit" }); console.log(`${YELLOW}-> Compiling console using${NC} ${BLUE}webpack${NC}`); - execSync(`webpack --mode production --env console=true --env platform=${platform}`); + execFileSync("webpack", ["--mode", "production", "--env", "console=true", "--env", `platform=${platform}`], { stdio: "inherit" }); console.log(`${GREEN}-> Console compiled successfully${NC}`); console.log(`${YELLOW}Building for ${platform}...${NC}`); - await build(); + await build({ packageType }); console.log(`${GREEN}Build completed successfully${NC}`); } catch (error) { console.error(error); diff --git a/dev/macos/build.js b/dev/macos/build.js index fbfcf60..575e503 100644 --- a/dev/macos/build.js +++ b/dev/macos/build.js @@ -1,12 +1,41 @@ -import { exec } from "node:child_process"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { runCommand } from "./command.js"; +import { createDmgPackage } from "./dmg.js"; -export default async function build() { - const root = join(process.cwd(), "platforms/macos"); - await new Promise((resolve, reject) => { - const child = exec(`xcodebuild archive -project "${join(root, "shellular.xcodeproj")}" -scheme shellular -configuration Release -destination 'generic/platform=macOS' -archivePath "${join(root, "shellular.xcarchive")}"`); - child.stdout?.pipe(process.stdout); child.stderr?.pipe(process.stderr); - child.on("close", code => code === 0 ? resolve() : reject(new Error(`xcodebuild exited ${code}`))); - child.on("error", reject); - }); +export default async function build({ packageType } = {}) { + const appRoot = process.cwd(); + const macosRoot = join(appRoot, "platforms", "macos"); + const projectPath = join(macosRoot, "shellular.xcodeproj"); + + if (packageType === "dmg") { + const packageJson = JSON.parse(await readFile(join(appRoot, "package.json"), "utf8")); + await createDmgPackage({ + appRoot, + macosRoot, + projectPath, + version: packageJson.version, + versionCode: packageJson.versionCode, + }); + return; + } + if (packageType) { + throw new Error(`Unsupported macOS package type: ${packageType}`); + } + + const archivePath = join(macosRoot, "shellular.xcarchive"); + await runCommand("xcodebuild", [ + "archive", + "-project", + projectPath, + "-scheme", + "shellular", + "-configuration", + "Release", + "-destination", + "generic/platform=macOS", + "-archivePath", + archivePath, + ]); + console.log(`Archive created at: ${archivePath}`); } diff --git a/dev/macos/command.js b/dev/macos/command.js new file mode 100644 index 0000000..f511588 --- /dev/null +++ b/dev/macos/command.js @@ -0,0 +1,50 @@ +import { spawn } from "node:child_process"; + +export class CommandExecutionError extends Error { + constructor(command, args, code, stdout, stderr) { + const detail = stderr.trim() || stdout.trim(); + super(`${command} exited with code ${code}${detail ? `: ${detail}` : ""}`); + this.name = "CommandExecutionError"; + this.command = command; + this.args = args; + this.code = code; + this.stdout = stdout; + this.stderr = stderr; + } +} + +export function runCommand(command, args, options = {}) { + const { capture = false, cwd, env = process.env } = options; + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env, + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + let stdout = ""; + let stderr = ""; + + if (capture) { + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk; + }); + } + + child.on("error", (error) => { + reject(new CommandExecutionError(command, args, "unavailable", stdout, stderr || error.message)); + }); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject(new CommandExecutionError(command, args, code, stdout, stderr)); + }); + }); +} diff --git a/dev/macos/command.test.js b/dev/macos/command.test.js new file mode 100644 index 0000000..3bc5cff --- /dev/null +++ b/dev/macos/command.test.js @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { CommandExecutionError, runCommand } from "./command.js"; + +describe("runCommand", () => { + it("captures stdout and stderr without a shell", async () => { + const result = await runCommand(process.execPath, ["-e", 'process.stdout.write("out"); process.stderr.write("err")'], { capture: true }); + + expect(result).toEqual({ stdout: "out", stderr: "err" }); + }); + + it("returns structured output when a command fails", async () => { + const execution = runCommand(process.execPath, ["-e", 'process.stdout.write("out"); process.stderr.write("failure"); process.exit(7)'], { + capture: true, + }); + + await expect(execution).rejects.toMatchObject({ + name: "CommandExecutionError", + code: 7, + stdout: "out", + stderr: "failure", + }); + await execution.catch((error) => { + expect(error).toBeInstanceOf(CommandExecutionError); + }); + }); +}); diff --git a/dev/macos/dmg.js b/dev/macos/dmg.js new file mode 100644 index 0000000..4d466d4 --- /dev/null +++ b/dev/macos/dmg.js @@ -0,0 +1,324 @@ +import * as defaultFileSystem from "node:fs/promises"; +import { join } from "node:path"; +import { runCommand as defaultRunCommand } from "./command.js"; + +const TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/; +const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; + +export function parseBuildSettings(output) { + const settings = new Map(); + for (const line of output.split("\n")) { + const match = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/); + if (match) settings.set(match[1], match[2]); + } + return settings; +} + +export function findDeveloperIdIdentity(output, teamId) { + const identities = []; + const pattern = /([0-9A-Fa-f]{40})\s+"(Developer ID Application:[^"]+\(([A-Z0-9]{10})\))"/g; + let match = pattern.exec(output); + while (match) { + if (match[3] === teamId) { + identities.push({ hash: match[1], name: match[2], teamId: match[3] }); + } + match = pattern.exec(output); + } + return identities[0]; +} + +export function parseCodeSigningDetails(output) { + const authority = output.match(/^Authority=(Developer ID Application:.+)$/m)?.[1]?.trim(); + const teamId = output.match(/^TeamIdentifier=(.+)$/m)?.[1]?.trim(); + const hardenedRuntime = /^CodeDirectory .+flags=.*\bruntime\b.*$/m.test(output); + return { authority, teamId, hardenedRuntime }; +} + +export function parseNotaryResponse(output) { + const trimmed = output.trim(); + if (!trimmed) throw new Error("The Apple notary service returned an empty response"); + + try { + return JSON.parse(trimmed); + } catch { + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)); + } + throw new Error("The Apple notary service returned invalid JSON"); + } +} + +export async function createDmgPackage(options, dependencies = {}) { + const { appRoot, macosRoot, projectPath, version, versionCode } = options; + const run = dependencies.run ?? defaultRunCommand; + const fileSystem = dependencies.fileSystem ?? defaultFileSystem; + const logger = dependencies.logger ?? console; + const hostPlatform = dependencies.hostPlatform ?? process.platform; + const signingConfigPath = join(macosRoot, "Signing.xcconfig"); + const distDirectory = join(appRoot, "dist"); + const finalDmgPath = join(distDirectory, `Shellular-${version}.dmg`); + let workspace; + + validatePackageMetadata(version, versionCode); + if (hostPlatform !== "darwin") { + throw new Error("DMG packages can only be built on macOS"); + } + + try { + await fileSystem.access(signingConfigPath); + } catch { + throw new Error(`Missing ${signingConfigPath}. Copy Signing.xcconfig.example and configure your Apple team and notary profile.`); + } + + const settingsResult = await run( + "xcodebuild", + ["-project", projectPath, "-scheme", "shellular", "-configuration", "Release", "-xcconfig", signingConfigPath, "-showBuildSettings"], + { capture: true }, + ); + const settings = parseBuildSettings(settingsResult.stdout); + const teamId = settings.get("DEVELOPMENT_TEAM"); + const notaryProfile = settings.get("SHELLULAR_NOTARY_PROFILE"); + validateSigningSettings(teamId, notaryProfile); + + const identitiesResult = await run("security", ["find-identity", "-v", "-p", "codesigning"], { capture: true }); + const identity = findDeveloperIdIdentity(identitiesResult.stdout, teamId); + if (!identity) { + throw new Error(`No valid Developer ID Application identity was found for Apple team ${teamId}`); + } + + await run("xcrun", ["notarytool", "history", "--keychain-profile", notaryProfile, "--output-format", "json", "--no-progress"], { capture: true }); + + await fileSystem.mkdir(distDirectory, { recursive: true }); + workspace = await fileSystem.mkdtemp(join(distDirectory, ".shellular-dmg-")); + + const archivePath = join(workspace, "Shellular.xcarchive"); + const exportDirectory = join(workspace, "export"); + const exportOptionsPath = join(workspace, "ExportOptions.plist"); + const stagingDirectory = join(workspace, "staging"); + const stagedAppPath = join(stagingDirectory, "Shellular.app"); + const exportedAppPath = join(exportDirectory, "Shellular.app"); + const temporaryDmgPath = join(workspace, `Shellular-${version}.dmg`); + + try { + await fileSystem.writeFile(exportOptionsPath, createExportOptionsPlist(teamId), "utf8"); + + logger.log(`Archiving Shellular ${version} (${versionCode}) for Developer ID distribution...`); + await run("xcodebuild", [ + "archive", + "-project", + projectPath, + "-scheme", + "shellular", + "-configuration", + "Release", + "-destination", + "generic/platform=macOS", + "-archivePath", + archivePath, + "-xcconfig", + signingConfigPath, + "-allowProvisioningUpdates", + `MARKETING_VERSION=${version}`, + `CURRENT_PROJECT_VERSION=${versionCode}`, + "CODE_SIGN_ENTITLEMENTS=shellular/shellular.direct.entitlements", + "ENABLE_HARDENED_RUNTIME=YES", + "OTHER_CODE_SIGN_FLAGS=--timestamp", + ]); + + logger.log("Exporting the archive with a Developer ID Application certificate..."); + await run("xcodebuild", [ + "-exportArchive", + "-archivePath", + archivePath, + "-exportPath", + exportDirectory, + "-exportOptionsPlist", + exportOptionsPath, + "-allowProvisioningUpdates", + ]); + await fileSystem.access(exportedAppPath); + + const signing = await verifyExportedApp({ + appPath: exportedAppPath, + run, + teamId, + }); + + await fileSystem.mkdir(stagingDirectory, { recursive: true }); + await run("ditto", [exportedAppPath, stagedAppPath]); + await fileSystem.symlink("/Applications", join(stagingDirectory, "Applications")); + + logger.log("Creating and signing the compressed DMG..."); + await run("hdiutil", [ + "create", + "-volname", + "Shellular", + "-srcfolder", + stagingDirectory, + "-format", + "UDZO", + "-ov", + "-nospotlight", + temporaryDmgPath, + ]); + await run("codesign", ["--force", "--timestamp", "--sign", signing.authority, temporaryDmgPath]); + await run("codesign", ["--verify", "--strict", "--verbose=2", temporaryDmgPath]); + + const notaryResponse = await submitForNotarization({ + dmgPath: temporaryDmgPath, + logger, + notaryProfile, + run, + }); + logger.log(`Apple notarization accepted submission ${notaryResponse.id}`); + + await run("xcrun", ["stapler", "staple", "-v", temporaryDmgPath]); + await run("hdiutil", ["verify", temporaryDmgPath]); + await run("codesign", ["--verify", "--strict", "--verbose=2", temporaryDmgPath]); + await run("xcrun", ["stapler", "validate", "-v", temporaryDmgPath]); + await run("spctl", ["--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=2", temporaryDmgPath]); + + await fileSystem.rename(temporaryDmgPath, finalDmgPath); + logger.log(`Signed and notarized DMG created at: ${finalDmgPath}`); + return finalDmgPath; + } finally { + if (workspace) { + await fileSystem.rm(workspace, { recursive: true, force: true }); + } + } +} + +async function verifyExportedApp({ appPath, run, teamId }) { + await run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath]); + const detailsResult = await run("codesign", ["-d", "--verbose=4", appPath], { + capture: true, + }); + const details = parseCodeSigningDetails(`${detailsResult.stdout}\n${detailsResult.stderr}`); + + if (!details.authority || details.teamId !== teamId) { + throw new Error(`The exported app is not signed with a Developer ID Application certificate for Apple team ${teamId}`); + } + if (!details.hardenedRuntime) { + throw new Error("The exported app does not have Hardened Runtime enabled"); + } + + const entitlementsResult = await run("codesign", ["-d", "--entitlements", "-", appPath], { + capture: true, + }); + const entitlements = `${entitlementsResult.stdout}\n${entitlementsResult.stderr}`; + if (hasEnabledEntitlement(entitlements, "com.apple.security.get-task-allow")) { + throw new Error("The exported app contains the forbidden get-task-allow entitlement"); + } + if (hasEnabledEntitlement(entitlements, "com.apple.security.app-sandbox")) { + throw new Error("The direct-download app was unexpectedly exported with App Sandbox enabled"); + } + + const architecturesResult = await run("lipo", ["-archs", join(appPath, "Contents/MacOS/Shellular")], { capture: true }); + const architectures = new Set(architecturesResult.stdout.trim().split(/\s+/)); + if (!architectures.has("arm64") || !architectures.has("x86_64")) { + throw new Error("The exported app is not a universal arm64/x86_64 build"); + } + + return details; +} + +async function submitForNotarization({ dmgPath, logger, notaryProfile, run }) { + let result; + try { + result = await run( + "xcrun", + [ + "notarytool", + "submit", + dmgPath, + "--keychain-profile", + notaryProfile, + "--wait", + "--timeout", + "30m", + "--output-format", + "json", + "--no-progress", + ], + { capture: true }, + ); + } catch (error) { + const response = tryParseNotaryResponse(error.stdout || error.stderr || ""); + if (response?.id) await printNotaryLog(response.id, notaryProfile, run, logger); + throw error; + } + + const response = parseNotaryResponse(result.stdout || result.stderr); + if (response.status !== "Accepted") { + if (response.id) await printNotaryLog(response.id, notaryProfile, run, logger); + throw new Error(`Apple notarization failed with status ${response.status || "Unknown"}${response.id ? ` (submission ${response.id})` : ""}`); + } + return response; +} + +async function printNotaryLog(submissionId, notaryProfile, run, logger) { + try { + const result = await run("xcrun", ["notarytool", "log", submissionId, "--keychain-profile", notaryProfile, "--output-format", "json"], { + capture: true, + }); + logger.error(result.stdout || result.stderr); + } catch (error) { + logger.error(`Unable to retrieve notarization log: ${error.message}`); + } +} + +function validatePackageMetadata(version, versionCode) { + if (!VERSION_PATTERN.test(version)) { + throw new Error(`Invalid package version: ${version}`); + } + if (!Number.isInteger(versionCode) || versionCode <= 0) { + throw new Error(`Invalid package versionCode: ${versionCode}`); + } +} + +function validateSigningSettings(teamId, notaryProfile) { + if (!teamId || !TEAM_ID_PATTERN.test(teamId)) { + throw new Error("Signing.xcconfig must define a valid 10-character DEVELOPMENT_TEAM"); + } + if (!notaryProfile || notaryProfile === "NOTARY_PROFILE_NAME") { + throw new Error("Signing.xcconfig must define SHELLULAR_NOTARY_PROFILE"); + } +} + +function hasEnabledEntitlement(entitlements, name) { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (new RegExp(`(?:${escapedName}\\s*|["']${escapedName}["']\\s*(?:=>|:)\\s*true)`).test(entitlements)) { + return true; + } + + const nativeBlock = entitlements.match(new RegExp(`\\[Key\\]\\s+${escapedName}([\\s\\S]*?)(?=\\n\\s*\\[Key\\]|$)`))?.[1]; + return !!nativeBlock && /\[Bool\]\s+true/.test(nativeBlock); +} + +function tryParseNotaryResponse(output) { + try { + return parseNotaryResponse(output); + } catch { + return undefined; + } +} + +function createExportOptionsPlist(teamId) { + return ` + + + + destination + export + method + developer-id + signingStyle + automatic + teamID + ${teamId} + + +`; +} diff --git a/dev/macos/dmg.test.js b/dev/macos/dmg.test.js new file mode 100644 index 0000000..ec6b92d --- /dev/null +++ b/dev/macos/dmg.test.js @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from "vitest"; +import { createDmgPackage, findDeveloperIdIdentity, parseBuildSettings, parseCodeSigningDetails, parseNotaryResponse } from "./dmg.js"; + +const TEAM_ID = "A1B2C3D4E5"; +const IDENTITY = `Developer ID Application: Shellular Company (${TEAM_ID})`; +const WORKSPACE = "/repo/app/dist/.shellular-dmg-test"; + +describe("DMG parsing helpers", () => { + it("parses resolved Xcode settings", () => { + const settings = parseBuildSettings(` + DEVELOPMENT_TEAM = ${TEAM_ID} + SHELLULAR_NOTARY_PROFILE = company-notary + `); + expect(settings.get("DEVELOPMENT_TEAM")).toBe(TEAM_ID); + expect(settings.get("SHELLULAR_NOTARY_PROFILE")).toBe("company-notary"); + }); + + it("selects only a Developer ID identity from the configured team", () => { + const output = [`1) ${"A".repeat(40)} "Developer ID Application: Other Company (Z9Y8X7W6V5)"`, `2) ${"B".repeat(40)} "${IDENTITY}"`].join("\n"); + expect(findDeveloperIdIdentity(output, TEAM_ID)).toEqual({ + hash: "B".repeat(40), + name: IDENTITY, + teamId: TEAM_ID, + }); + }); + + it("parses Developer ID and Hardened Runtime details", () => { + expect(parseCodeSigningDetails(signingDetails())).toEqual({ + authority: IDENTITY, + teamId: TEAM_ID, + hardenedRuntime: true, + }); + }); + + it("parses plain and prefixed notary JSON", () => { + expect(parseNotaryResponse('{"status":"Accepted","id":"one"}')).toEqual({ + status: "Accepted", + id: "one", + }); + expect(parseNotaryResponse('progress\n{"status":"Invalid","id":"two"}\n')).toEqual({ status: "Invalid", id: "two" }); + }); +}); + +describe("createDmgPackage", () => { + it("archives, exports, signs, notarizes, validates, and atomically publishes", async () => { + const harness = createHarness(); + + await expect(runPackage(harness)).resolves.toBe("/repo/app/dist/Shellular-1.2.3.dmg"); + + const archive = findCall(harness.calls, "xcodebuild", "archive"); + expect(archive.args).toEqual( + expect.arrayContaining([ + "MARKETING_VERSION=1.2.3", + "CURRENT_PROJECT_VERSION=123", + "CODE_SIGN_ENTITLEMENTS=shellular/shellular.direct.entitlements", + "ENABLE_HARDENED_RUNTIME=YES", + "OTHER_CODE_SIGN_FLAGS=--timestamp", + ]), + ); + + const exportOptions = harness.fileSystem.writeFile.mock.calls[0][1]; + expect(exportOptions).toContain("developer-id"); + expect(exportOptions).toContain(`${TEAM_ID}`); + + const dmgSigning = harness.calls.find(({ command, args }) => command === "codesign" && args[0] === "--force"); + expect(dmgSigning.args).toContain(IDENTITY); + + const submission = findCall(harness.calls, "xcrun", "notarytool", "submit"); + expect(submission.args).toEqual(expect.arrayContaining(["--keychain-profile", "company-notary", "--wait"])); + expect(harness.fileSystem.rename).toHaveBeenCalledWith(`${WORKSPACE}/Shellular-1.2.3.dmg`, "/repo/app/dist/Shellular-1.2.3.dmg"); + expect(harness.fileSystem.rm).toHaveBeenCalledWith(WORKSPACE, { + recursive: true, + force: true, + }); + }); + + it("fails before archiving when the configured team has no Developer ID identity", async () => { + const harness = createHarness({ identityTeamId: "Z9Y8X7W6V5" }); + + await expect(runPackage(harness)).rejects.toThrow(`No valid Developer ID Application identity was found for Apple team ${TEAM_ID}`); + expect(harness.calls.some(({ command, args }) => command === "xcodebuild" && args.includes("archive"))).toBe(false); + expect(harness.fileSystem.rename).not.toHaveBeenCalled(); + }); + + it("requires the local signing configuration", async () => { + const harness = createHarness(); + harness.fileSystem.access.mockRejectedValueOnce(new Error("missing")); + + await expect(runPackage(harness)).rejects.toThrow("Missing /repo/app/platforms/macos/Signing.xcconfig"); + expect(harness.run).not.toHaveBeenCalled(); + }); + + it.each([ + ["Hardened Runtime", { signingOutput: signingDetails().replace("(runtime)", "") }], + ["get-task-allow", { entitlements: entitlementXml("com.apple.security.get-task-allow") }], + ["App Sandbox", { entitlements: entitlementXml("com.apple.security.app-sandbox") }], + ["universal", { architectures: "arm64\n" }], + ])("rejects an invalid exported app: %s", async (_label, options) => { + const harness = createHarness(options); + + await expect(runPackage(harness)).rejects.toThrow(); + expect(harness.fileSystem.rename).not.toHaveBeenCalled(); + expect(harness.fileSystem.rm).toHaveBeenCalled(); + }); + + it("prints Apple's log and withholds an invalid notarization", async () => { + const harness = createHarness({ notaryStatus: "Invalid" }); + + await expect(runPackage(harness)).rejects.toThrow("Apple notarization failed with status Invalid"); + expect(findCall(harness.calls, "xcrun", "notarytool", "log")).toBeTruthy(); + expect(harness.logger.error).toHaveBeenCalledWith(expect.stringContaining("The signature does not include a secure timestamp")); + expect(harness.fileSystem.rename).not.toHaveBeenCalled(); + expect(harness.fileSystem.rm).toHaveBeenCalled(); + }); + + it.each([ + ["archive export", ({ command, args }) => command === "xcodebuild" && args[0] === "-exportArchive"], + ["DMG signing", ({ command, args }) => command === "codesign" && args[0] === "--force"], + ["ticket stapling", ({ command, args }) => command === "xcrun" && args[0] === "stapler" && args[1] === "staple"], + ["Gatekeeper assessment", ({ command }) => command === "spctl"], + ])("cleans temporary output when %s fails", async (_label, failurePredicate) => { + const harness = createHarness({ failurePredicate }); + + await expect(runPackage(harness)).rejects.toThrow("simulated command failure"); + expect(harness.fileSystem.rename).not.toHaveBeenCalled(); + expect(harness.fileSystem.rm).toHaveBeenCalledWith(WORKSPACE, { + recursive: true, + force: true, + }); + }); +}); + +function createHarness(options = {}) { + const calls = []; + const logger = { log: vi.fn(), error: vi.fn() }; + const fileSystem = { + access: vi.fn(async () => undefined), + mkdir: vi.fn(async () => undefined), + mkdtemp: vi.fn(async () => WORKSPACE), + writeFile: vi.fn(async () => undefined), + symlink: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), + }; + + const run = vi.fn(async (command, args, commandOptions = {}) => { + const call = { command, args, options: commandOptions }; + calls.push(call); + if (options.failurePredicate?.(call)) { + throw new Error("simulated command failure"); + } + + if (command === "xcodebuild" && args.includes("-showBuildSettings")) { + return result(` + DEVELOPMENT_TEAM = ${TEAM_ID} + SHELLULAR_NOTARY_PROFILE = company-notary + `); + } + if (command === "security") { + const identityTeamId = options.identityTeamId ?? TEAM_ID; + return result(`1) ${"A".repeat(40)} "Developer ID Application: Shellular Company (${identityTeamId})"`); + } + if (command === "codesign" && args[0] === "-d" && args.includes("--verbose=4")) { + return result("", options.signingOutput ?? signingDetails()); + } + if (command === "codesign" && args[0] === "-d" && args.includes("--entitlements")) { + return result(options.entitlements ?? directEntitlements()); + } + if (command === "lipo") { + return result(options.architectures ?? "x86_64 arm64\n"); + } + if (command === "xcrun" && args[0] === "notarytool" && args[1] === "submit") { + return result( + JSON.stringify({ + status: options.notaryStatus ?? "Accepted", + id: "submission-id", + }), + ); + } + if (command === "xcrun" && args[0] === "notarytool" && args[1] === "log") { + return result('{"issues":["The signature does not include a secure timestamp"]}'); + } + return result(); + }); + + return { calls, fileSystem, logger, run }; +} + +function runPackage(harness) { + return createDmgPackage( + { + appRoot: "/repo/app", + macosRoot: "/repo/app/platforms/macos", + projectPath: "/repo/app/platforms/macos/shellular.xcodeproj", + version: "1.2.3", + versionCode: 123, + }, + { + fileSystem: harness.fileSystem, + hostPlatform: "darwin", + logger: harness.logger, + run: harness.run, + }, + ); +} + +function findCall(calls, command, ...requiredArguments) { + return calls.find((call) => call.command === command && requiredArguments.every((argument) => call.args.includes(argument))); +} + +function result(stdout = "", stderr = "") { + return { stdout, stderr }; +} + +function signingDetails() { + return [ + "CodeDirectory v=20500 size=123 flags=0x10000(runtime) hashes=10+7 location=embedded", + `Authority=${IDENTITY}`, + `TeamIdentifier=${TEAM_ID}`, + ].join("\n"); +} + +function directEntitlements() { + return `[Dict] + [Key] com.apple.security.network.client + [Value] + [Bool] true`; +} + +function entitlementXml(name) { + return `[Dict] + [Key] ${name} + [Value] + [Bool] true`; +} diff --git a/dev/project-tree-benchmark.js b/dev/project-tree-benchmark.js new file mode 100644 index 0000000..e2e8afa --- /dev/null +++ b/dev/project-tree-benchmark.js @@ -0,0 +1,29 @@ +import { performance } from "node:perf_hooks"; +import { preparePresortedFileTreeInput } from "@pierre/trees"; + +const directoryCount = 100; +const filesPerDirectory = 1_000; +const rootPaths = Array.from({ length: directoryCount }, (_, index) => `folder-${String(index).padStart(3, "0")}/`); +const repositoryPaths = rootPaths.flatMap((directory) => [ + directory, + ...Array.from({ length: filesPerDirectory }, (_, index) => `${directory}file-${String(index).padStart(4, "0")}.ts`), +]); + +if (repositoryPaths.length !== 100_100) { + throw new Error(`Expected 100,100 deterministic paths, got ${repositoryPaths.length}`); +} + +preparePresortedFileTreeInput(rootPaths); +const iterations = 20; +const started = performance.now(); +for (let index = 0; index < iterations; index += 1) { + preparePresortedFileTreeInput(rootPaths); +} +const averageMs = (performance.now() - started) / iterations; + +console.log( + `Lazy project explorer: ${rootPaths.length} root entries from a ${repositoryPaths.length.toLocaleString()}-path repository in ${averageMs.toFixed(2)}ms average.`, +); +if (averageMs >= 50) { + throw new Error(`Root preparation exceeded the 50ms budget (${averageMs.toFixed(2)}ms).`); +} diff --git a/dev/terminals.sh b/dev/terminals.sh index c680d26..4a86eb5 100755 --- a/dev/terminals.sh +++ b/dev/terminals.sh @@ -57,9 +57,9 @@ else fi if [ -n "$SSH_USER" ]; then - WSS_SERVER="wss://${SSH_USER}.${SSH_HOST}" + WSS_SERVER="https://${SSH_USER}.${SSH_HOST}" else - WSS_SERVER="wss://${SSH_HOST}" + WSS_SERVER="https://${SSH_HOST}" fi LEFT_X=$DISPLAY_OFFSET_X diff --git a/package.json b/package.json index 4f33b58..4a990e7 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "io.foxbiz.shellular", "displayName": "Use your dev machine from your phone.", - "version": "0.0.35", - "versionCode": 35, + "version": "0.0.36", + "versionCode": 36, "description": "", "type": "module", "licenses": [ @@ -21,6 +21,7 @@ "lang": "node dev/lang", "test": "vitest run", "test:watch": "vitest", + "benchmark:project-tree": "node dev/project-tree-benchmark.js", "format": "biome check --write" }, "author": "Shellular Team (https://shellular.dev)", @@ -47,6 +48,7 @@ "css-loader": "^7.1.4", "jsdom": "^29.1.1", "mini-css-extract-plugin": "^2.10.2", + "monaco-editor-webpack-plugin": "7.1.1", "postcss": "^8.5.12", "postcss-loader": "^8.2.1", "raw-loader": "^4.0.2", @@ -65,6 +67,9 @@ "xml2js": "^0.6.2" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@codemirror/commands": "^6.10.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", @@ -81,7 +86,8 @@ "@headlessui/react": "^2.2.10", "@lezer/highlight": "^1.2.3", "@pierre/diffs": "^1.1.20", - "@shellular/protocol": "^0.0.26", + "@pierre/trees": "1.0.0-beta.5", + "@shellular/protocol": "^0.0.29", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", @@ -98,6 +104,7 @@ "jsqr": "^1.4.0", "libsodium-wrappers": "^0.8.4", "marked": "^18.0.2", + "monaco-editor": "0.55.1", "nanoid": "^5.1.11", "react": "^19.2.5", "react-chartjs-2": "^5.3.1", @@ -113,4 +120,4 @@ "engines": { "node": ">=22.13" } -} \ No newline at end of file +} diff --git a/platforms/android/app/src/main/AndroidManifest.xml b/platforms/android/app/src/main/AndroidManifest.xml index 04aa542..83c1205 100644 --- a/platforms/android/app/src/main/AndroidManifest.xml +++ b/platforms/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ { + if (barcodes.isEmpty()) { + callback.error("No QR code was found in this image. Please try another image."); + return; + } + try { + callback.success(ScannerView.getJsonArray(barcodes)); + } catch (Exception e) { + callback.error(e.toString()); + } + }) + .addOnFailureListener(e -> callback.error(e.toString())); + } catch (Exception e) { + callback.error(e.toString()); + } + } + public void setTheme(JSONArray args, Callback callback) { try { theme = new Theme(args.optJSONObject(0)); diff --git a/platforms/android/app/src/main/java/io/foxbiz/shellular/scanner/ScannerView.java b/platforms/android/app/src/main/java/io/foxbiz/shellular/scanner/ScannerView.java index 99faaff..9850607 100644 --- a/platforms/android/app/src/main/java/io/foxbiz/shellular/scanner/ScannerView.java +++ b/platforms/android/app/src/main/java/io/foxbiz/shellular/scanner/ScannerView.java @@ -221,7 +221,7 @@ public void analyze(ImageProxy imageProxy) { } @NonNull - private static JSONArray getJsonArray(List barcodes) throws JSONException { + static JSONArray getJsonArray(List barcodes) throws JSONException { JSONArray result = new JSONArray(); for (Barcode barcode : barcodes) { JSONObject obj = new JSONObject(); diff --git a/platforms/ios/shellular.xcodeproj/project.pbxproj b/platforms/ios/shellular.xcodeproj/project.pbxproj index d1af39c..2a22c84 100644 --- a/platforms/ios/shellular.xcodeproj/project.pbxproj +++ b/platforms/ios/shellular.xcodeproj/project.pbxproj @@ -278,7 +278,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Shellular; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = shellular/Info.plist; @@ -289,7 +289,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.35; + MARKETING_VERSION = 0.0.36; PRODUCT_BUNDLE_IDENTIFIER = io.foxbiz.shellular; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -311,7 +311,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Shellular; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = shellular/Info.plist; @@ -322,7 +322,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.35; + MARKETING_VERSION = 0.0.36; PRODUCT_BUNDLE_IDENTIFIER = io.foxbiz.shellular; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/platforms/ios/shellular/scanner/ScannerService.swift b/platforms/ios/shellular/scanner/ScannerService.swift index cbbbde1..e4952c0 100644 --- a/platforms/ios/shellular/scanner/ScannerService.swift +++ b/platforms/ios/shellular/scanner/ScannerService.swift @@ -1,5 +1,6 @@ import UIKit import AVFoundation +import Vision final class ScannerService: BaseService { @@ -20,6 +21,7 @@ final class ScannerService: BaseService { case "show": show(args: args, callback: callback) case "hide": hide(args: args, callback: callback) case "scan": scan(args: args, callback: callback) + case "scanImage": scanImage(args: args, callback: callback) case "update": update(args: args, callback: callback) case "setTheme": setTheme(args: args, callback: callback) case "setOnShowListener": onShowCallback = callback; callback.success(nil, keep: true) @@ -111,6 +113,51 @@ final class ScannerService: BaseService { // Match Android: keep the callback pending until a scan result is emitted. } + // MARK: - scanImage + + private func scanImage(args: [Any], callback: Callback) { + guard let base64 = args[safe: 0] as? String, + let data = Data(base64Encoded: base64), + let image = UIImage(data: data), + let cgImage = image.cgImage else { + callback.error("Could not read this image. Please try another file.") + return + } + + let request = VNDetectBarcodesRequest { request, error in + if let error = error { + callback.error(error.localizedDescription) + return + } + + let results: [[String: Any]] = (request.results as? [VNBarcodeObservation] ?? []) + .filter { $0.symbology == .qr } + .compactMap { observation in + guard let value = observation.payloadStringValue else { return nil } + return [ + "rawValue": value, + "displayValue": value, + "format": "256", // FORMAT_QR_CODE numeric value + "valueType": "0", + ] + } + + if results.isEmpty { + callback.error("No QR code was found in this image. Please try another image.") + } else { + callback.success(results) + } + } + request.symbologies = [.qr] + + let handler = VNImageRequestHandler(cgImage: cgImage, orientation: image.cgImagePropertyOrientation, options: [:]) + do { + try handler.perform([request]) + } catch { + callback.error(error.localizedDescription) + } + } + // MARK: - setTheme private func setTheme(args: [Any], callback: Callback) { @@ -241,6 +288,24 @@ final class ScannerService: BaseService { } } +// MARK: - UIImage orientation → CGImagePropertyOrientation + +private extension UIImage { + var cgImagePropertyOrientation: CGImagePropertyOrientation { + switch imageOrientation { + case .up: return .up + case .upMirrored: return .upMirrored + case .down: return .down + case .downMirrored: return .downMirrored + case .left: return .left + case .leftMirrored: return .leftMirrored + case .right: return .right + case .rightMirrored: return .rightMirrored + @unknown default: return .up + } + } +} + // MARK: - AVCaptureMetadataOutputObjectsDelegate extension ScannerService: AVCaptureMetadataOutputObjectsDelegate { diff --git a/platforms/macos/Package.swift b/platforms/macos/Package.swift new file mode 100644 index 0000000..8e0d18e --- /dev/null +++ b/platforms/macos/Package.swift @@ -0,0 +1,50 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "ShellularMacOS", + platforms: [.macOS(.v14)], + products: [ + .library(name: "WindowChromeSupport", targets: ["WindowChromeSupport"]), + ], + targets: [ + .target( + name: "WindowChromeSupport", + path: "shellular", + exclude: [ + ".DS_Store", + "App.swift", + "Assets.xcassets", + "Bridge.swift", + "BrowserService.swift", + "EmbeddedProxyServer.swift", + "EmbeddedProxyService.swift", + "Info.plist", + "LocalCLIService.swift", + "ScannerService.swift", + "Services.swift", + "ShellularWindow.swift", + "Shellular.icon", + "SocketService.swift", + "WebView.swift", + "bundle", + "shellular.direct.entitlements", + "shellular.entitlements", + ], + sources: [ + "BrowserSupport.swift", + "ContextMenuSupport.swift", + "DesktopShortcutSupport.swift", + "LocalCLIActivationClient.swift", + "NativeThemeColor.swift", + "PrivateFileStore.swift", + ] + ), + .testTarget( + name: "WindowChromeSupportTests", + dependencies: ["WindowChromeSupport"], + path: "Tests/WindowChromeSupportTests" + ), + ] +) diff --git a/platforms/macos/Signing.xcconfig.example b/platforms/macos/Signing.xcconfig.example new file mode 100644 index 0000000..370fe70 --- /dev/null +++ b/platforms/macos/Signing.xcconfig.example @@ -0,0 +1,4 @@ +// Copy this file to Signing.xcconfig and replace the values below. +// Signing.xcconfig is ignored by Git. +DEVELOPMENT_TEAM = COMPANY_TEAM_ID +SHELLULAR_NOTARY_PROFILE = shellular-notary diff --git a/platforms/macos/Tests/WindowChromeSupportTests/BrowserSupportTests.swift b/platforms/macos/Tests/WindowChromeSupportTests/BrowserSupportTests.swift new file mode 100644 index 0000000..a3779ba --- /dev/null +++ b/platforms/macos/Tests/WindowChromeSupportTests/BrowserSupportTests.swift @@ -0,0 +1,2486 @@ +import Security +import WebKit +import XCTest +@testable import WindowChromeSupport + +private final class BrowserAttachmentWidthRecordingView: NSView { + private(set) var assignedWidths: [CGFloat] = [] + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + assignedWidths.append(newSize.width) + } + + func resetAssignedWidths() { + assignedWidths.removeAll() + } +} + +private final class BrowserOwnedDocumentNavigationProbe: NSObject, WKNavigationDelegate { + let finished: XCTestExpectation + private(set) var shellularMainFrameURLs: [URL] = [] + private(set) var failure: Error? + + init(finished: XCTestExpectation) { + self.finished = finished + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + if navigationAction.targetFrame?.isMainFrame != false, + let url = navigationAction.request.url, + url.scheme == "shellular" { + shellularMainFrameURLs.append(url) + } + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + finished.fulfill() + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + failure = error + finished.fulfill() + } + + func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error + ) { + failure = error + finished.fulfill() + } +} + +private final class BrowserTestAssetSchemeHandler: NSObject, WKURLSchemeHandler { + private(set) var requestedURLs: [URL] = [] + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(URLError(.badURL)) + return + } + requestedURLs.append(url) + let css = Data("#marker { color: rgb(12, 34, 56); }".utf8) + let response = URLResponse( + url: url, + mimeType: "text/css", + expectedContentLength: css.count, + textEncodingName: "utf-8" + ) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(css) + urlSchemeTask.didFinish() + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} +} + +private final class BrowserInspectMenuTarget: NSObject { + @objc func inspectElement(_ sender: Any?) {} +} + +final class BrowserAddressResolverTests: XCTestCase { + func testPreservesExplicitHTTPAndHTTPSAddresses() { + XCTAssertEqual( + BrowserAddressResolver.resolve("http://example.com:8080/a?q=1#result")?.absoluteString, + "http://example.com:8080/a?q=1#result" + ) + XCTAssertEqual( + BrowserAddressResolver.resolve("https://example.com/path")?.absoluteString, + "https://example.com/path" + ) + } + + func testDefaultsDevelopmentAddressesToHTTP() { + let cases = [ + "localhost:3000/path": "http://localhost:3000/path", + "app.localhost:5173": "http://app.localhost:5173", + "127.0.0.1:8080": "http://127.0.0.1:8080", + "10.1.2.3/app": "http://10.1.2.3/app", + "172.16.0.1": "http://172.16.0.1", + "192.168.1.8": "http://192.168.1.8", + "169.254.1.2": "http://169.254.1.2", + "[::1]:8443/path?q=1#part": "http://[::1]:8443/path?q=1#part", + "[fd00::1]/": "http://[fd00::1]/", + "example.com:3000": "http://example.com:3000", + ] + + for (input, expected) in cases { + XCTAssertEqual( + BrowserAddressResolver.resolve(input)?.absoluteString, + expected, + input + ) + } + } + + func testDefaultsPublicDomainsToHTTPSAndPhrasesToSearch() { + XCTAssertEqual( + BrowserAddressResolver.resolve("example.com/docs?q=swift")?.absoluteString, + "https://example.com/docs?q=swift" + ) + XCTAssertEqual( + BrowserAddressResolver.resolve("browser sidebar design")?.host, + "www.google.com" + ) + XCTAssertEqual( + URLComponents( + url: BrowserAddressResolver.resolve("browser sidebar design")!, + resolvingAgainstBaseURL: false + )?.queryItems?.first?.value, + "browser sidebar design" + ) + } + + func testNormalizesUnspecifiedDestinationsToLocalhost() { + XCTAssertEqual( + BrowserAddressResolver.resolve("0.0.0.0:3000/path")?.absoluteString, + "http://localhost:3000/path" + ) + XCTAssertEqual( + BrowserAddressResolver.resolve("0.0.0.0")?.absoluteString, + "http://localhost" + ) + XCTAssertEqual( + BrowserAddressResolver.resolve("::")?.absoluteString, + "http://localhost" + ) + XCTAssertEqual( + BrowserAddressResolver.resolve("https://[::]:8443/path")?.absoluteString, + "https://localhost:8443/path" + ) + } + + func testRejectsDangerousAndUnsupportedSchemes() { + for input in [ + "javascript:alert(1)", + "data:text/html,unsafe", + "file:///etc/passwd", + "ftp://example.com", + ] { + XCTAssertNil(BrowserAddressResolver.resolve(input), input) + } + } + + func testPreservesAndRestoresShellularAddresses() { + XCTAssertEqual( + BrowserAddressResolver.resolve("shellular://home")?.absoluteString, + "shellular://home" + ) + XCTAssertEqual( + BrowserAddressResolver.resolve("shellular://ports")?.absoluteString, + "shellular://ports" + ) + XCTAssertTrue(BrowserAddressResolver.isRestorable("shellular://home")) + XCTAssertTrue(BrowserAddressResolver.isRestorable("shellular://future-page")) + XCTAssertFalse(BrowserAddressResolver.isRestorable("shellular:")) + } +} + +final class BrowserOwnedDocumentTests: XCTestCase { + func testLogicalAddressIsSeparateFromNeutralWebKitBaseURL() { + for route in ["home", "ports"] { + let document = BrowserOwnedDocument( + displayURL: "shellular://\(route)", + html: "
\(route)
" + ) + XCTAssertEqual(document.displayURL, "shellular://\(route)") + XCTAssertNil(document.baseURL) + } + XCTAssertTrue(BrowserOwnedDocument.isPhysicalHTMLNavigation(URL(string: "about:blank")!)) + XCTAssertFalse(BrowserOwnedDocument.isPhysicalHTMLNavigation(URL(string: "shellular://home")!)) + } + + func testHomeAndPortsRenderWithoutShellularMainFrameNavigation() { + let finished = expectation(description: "owned documents finish") + finished.expectedFulfillmentCount = 2 + let rendered = expectation(description: "owned document DOM renders") + rendered.expectedFulfillmentCount = 2 + var webViews: [WKWebView] = [] + var probes: [BrowserOwnedDocumentNavigationProbe] = [] + var assetHandlers: [BrowserTestAssetSchemeHandler] = [] + + for route in ["home", "ports"] { + let assetHandler = BrowserTestAssetSchemeHandler() + let configuration = WKWebViewConfiguration() + configuration.setURLSchemeHandler(assetHandler, forURLScheme: "shellular") + let webView = WKWebView( + frame: NSRect(x: 0, y: 0, width: 400, height: 500), + configuration: configuration + ) + let probe = BrowserOwnedDocumentNavigationProbe(finished: finished) + webView.navigationDelegate = probe + let document = BrowserOwnedDocument( + displayURL: "shellular://\(route)", + html: """ + +
\(route)
+ """ + ) + document.load(in: webView) + webViews.append(webView) + probes.append(probe) + assetHandlers.append(assetHandler) + } + + wait(for: [finished], timeout: 5) + for (index, webView) in webViews.enumerated() { + webView.evaluateJavaScript( + "document.getElementById('marker')?.textContent + '|' + getComputedStyle(document.getElementById('marker')).color" + ) { value, error in + XCTAssertNil(error) + let route = index == 0 ? "home" : "ports" + XCTAssertEqual(value as? String, "\(route)|rgb(12, 34, 56)") + rendered.fulfill() + } + } + wait(for: [rendered], timeout: 5) + + for probe in probes { + XCTAssertNil(probe.failure) + XCTAssertTrue(probe.shellularMainFrameURLs.isEmpty) + } + for handler in assetHandlers { + XCTAssertEqual(handler.requestedURLs.map(\.absoluteString), ["shellular://assets/test.css"]) + } + } +} + +final class BrowserDeveloperToolsScriptTests: XCTestCase { + private static let stubErudaRuntime = """ + window.__stubErudaInitCount = window.__stubErudaInitCount || 0; + (function(){ + var listeners = {}; + var panel = document.createElement("div"); + panel.className = "eruda-dev-tools"; + panel.style.cssText = "position:fixed;left:0;bottom:0;width:100%;height:45vh;display:none"; + document.documentElement.appendChild(panel); + var entryButton = { + hide: function(){ window.__stubErudaEntryHidden = true; } + }; + var tools = { + on: function(name, listener){ listeners[name] = listener; } + }; + var elements = { + _detail: { + hide: function(){ + window.__stubErudaElementHighlighted = false; + window.__stubErudaHighlightClearCount = + (window.__stubErudaHighlightClearCount || 0) + 1; + } + }, + select: function(element){ + window.__stubErudaSelectedElementId = element && element.id; + window.__stubErudaElementHighlighted = true; + } + }; + window.eruda = { + _shadowRoot: document, + init: function(options){ + window.__stubErudaInitCount += 1; + window.__stubErudaOptions = options; + }, + get: function(name){ + if (name === "entryBtn") return entryButton; + if (name === "elements") return elements; + return tools; + }, + show: function(toolName){ + if (toolName) { + window.__stubErudaShownTool = toolName; + return; + } + panel.style.display = "block"; + window.__stubErudaVisible = true; + window.__stubErudaRevealCount = (window.__stubErudaRevealCount || 0) + 1; + if (listeners.show) listeners.show(); + }, + hide: function(){ + panel.style.display = "none"; + window.__stubErudaVisible = false; + if (listeners.hide) listeners.hide(); + } + }; + window.__shellularInstallDeveloperTools = function(options){ + if (window.__shellularDeveloperTools) return window.__shellularDeveloperTools; + var visible = false; + var highlighted = false; + var contextTarget = null; + var panelPercent = Math.max(25, Math.min(70, options.panelPercent || 45)); + function spacer(){ + var value = document.getElementById("__shellularDeveloperToolsSpacer"); + if (!value) { + value = document.createElement("div"); + value.id = "__shellularDeveloperToolsSpacer"; + } + document.body.appendChild(value); + value.style.display = visible ? "block" : "none"; + value.style.height = visible ? Math.ceil(panel.getBoundingClientRect().height) + "px" : "0px"; + return value; + } + function clearHighlight(){ + elements._detail.hide(); + highlighted = false; + return true; + } + var api = { + version: 1, + show: function(tool){ + visible = true; + if (tool) window.eruda.show(tool); + window.eruda.show(); + spacer(); + return true; + }, + hide: function(){ + if (highlighted) clearHighlight(); + visible = false; + window.eruda.hide(); + spacer(); + return true; + }, + toggle: function(){ return visible ? api.hide() : api.show(); }, + inspectElement: function(target){ + target = target || contextTarget || document.documentElement; + highlighted = true; + api.show("elements"); + elements.select(target); + return true; + }, + clearHighlight: clearHighlight, + setPanelPercent: function(value){ + panelPercent = Math.max(25, Math.min(70, value)); + panel.style.height = panelPercent + "vh"; + window.__stubErudaOptions.defaults.displaySize = panelPercent; + spacer(); + return panelPercent; + }, + getState: function(){ + return { version: 1, ready: true, visible: visible, panelPercent: panelPercent, highlighted: highlighted }; + } + }; + document.addEventListener("contextmenu", function(event){ contextTarget = event.target; }, true); + window.eruda.init({ autoScale: false, defaults: { displaySize: panelPercent, theme: "System preference" } }); + entryButton.hide(); + window.__shellularDeveloperTools = api; + window.__shellularSetDeveloperToolsVisible = function(value){ return value ? api.show() : api.hide(); }; + window.__shellularInspectContextElement = function(){ return api.inspectElement(); }; + window.__shellularClearElementHighlight = function(){ return api.clearHighlight(); }; + spacer(); + return api; + }; + })(); + """ + + func testVisibilityStateIsIndependentPerTab() { + var first = BrowserDeveloperToolsState() + let second = BrowserDeveloperToolsState() + + XCTAssertTrue(first.toggle()) + XCTAssertTrue(first.isVisible) + XCTAssertFalse(second.isVisible) + XCTAssertFalse(first.toggle()) + XCTAssertFalse(first.isVisible) + + first.show() + XCTAssertTrue(first.isVisible) + XCTAssertFalse(second.isVisible) + + first.showElementHighlight() + XCTAssertTrue(first.isVisible) + XCTAssertTrue(first.isElementHighlighted) + first.clearElementHighlight() + XCTAssertTrue(first.isVisible) + XCTAssertFalse(first.isElementHighlighted) + + first.showElementHighlight() + first.hide() + XCTAssertFalse(first.isVisible) + XCTAssertFalse(first.isElementHighlighted) + XCTAssertFalse(second.isVisible) + } + + func testDeveloperToolsShortcutSupportsShiftOrOptionCommandI() throws { + XCTAssertTrue(BrowserDeveloperToolsShortcut.matches(try keyEvent("i", [.command, .option]))) + XCTAssertTrue(BrowserDeveloperToolsShortcut.matches(try keyEvent("I", [.command, .option]))) + XCTAssertTrue(BrowserDeveloperToolsShortcut.matches(try keyEvent("i", [.command, .shift]))) + XCTAssertTrue(BrowserDeveloperToolsShortcut.matches(try keyEvent("I", [.command, .shift]))) + XCTAssertFalse(BrowserDeveloperToolsShortcut.matches(try keyEvent("i", [.command]))) + XCTAssertFalse(BrowserDeveloperToolsShortcut.matches(try keyEvent("i", [.command, .option, .shift]))) + XCTAssertFalse(BrowserDeveloperToolsShortcut.matches(try keyEvent("i", [.command, .control, .shift]))) + XCTAssertFalse(BrowserDeveloperToolsShortcut.matches(try keyEvent("j", [.command, .option]))) + XCTAssertFalse(BrowserDeveloperToolsShortcut.matches(try keyEvent("j", [.command, .shift]))) + } + + func testCreatesDocumentStartMainFrameOnlyUserScript() { + let script = BrowserDeveloperToolsScript.makeUserScript( + erudaSource: Self.stubErudaRuntime + ) + + XCTAssertEqual(script.injectionTime, .atDocumentStart) + XCTAssertTrue(script.isForMainFrameOnly) + XCTAssertTrue(script.source.contains("__shellularDeveloperTools")) + XCTAssertTrue(script.source.contains("__shellularInstallDeveloperTools")) + XCTAssertTrue(script.source.contains("panelPercent: 45.0")) + XCTAssertTrue(script.source.contains("shellularDeveloperTools")) + XCTAssertTrue(script.source.contains("document.readyState === \"loading\"")) + XCTAssertTrue(script.source.contains(Self.stubErudaRuntime)) + XCTAssertFalse(script.source.contains("function createMemoryStorage")) + } + + func testMissingRuntimeFailsWithoutProducingAUserScript() { + let missingResources = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + XCTAssertNil(BrowserDeveloperToolsScript.loadRuntime(resourceURL: missingResources)) + XCTAssertNil(BrowserDeveloperToolsScript.loadRuntime(resourceURL: nil)) + } + + func testRuntimeLoadsFromBundleInsideResourcesDirectory() throws { + let resources = try makeTemporaryResourcesDirectory() + let bundle = resources.appendingPathComponent("bundle", isDirectory: true) + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + try "bundled-runtime".write( + to: bundle.appendingPathComponent("console.js"), + atomically: true, + encoding: .utf8 + ) + + XCTAssertEqual( + BrowserDeveloperToolsScript.loadRuntime(resourceURL: resources), + "bundled-runtime" + ) + } + + func testRuntimeSkipsEmptyBundleFileAndFallsBackToResourcesRoot() throws { + let resources = try makeTemporaryResourcesDirectory() + let bundle = resources.appendingPathComponent("bundle", isDirectory: true) + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + try "".write( + to: bundle.appendingPathComponent("console.js"), + atomically: true, + encoding: .utf8 + ) + try "root-runtime".write( + to: resources.appendingPathComponent("console.js"), + atomically: true, + encoding: .utf8 + ) + + XCTAssertEqual( + BrowserDeveloperToolsScript.loadRuntime(resourceURL: resources), + "root-runtime" + ) + } + + func testInjectedRuntimeInitializesOnceAndFollowsVisibilityCommands() throws { + let loaded = expectation(description: "document loads with developer tools") + let initialLayout = expectation(description: "hidden spacer follows parsed page content") + let shown = expectation(description: "developer tools are shown") + let hidden = expectation(description: "developer tools are hidden") + let configuration = WKWebViewConfiguration() + let userScript = BrowserDeveloperToolsScript.makeUserScript( + erudaSource: Self.stubErudaRuntime + ) + configuration.userContentController.addUserScript(userScript) + let webView = WKWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 500), + configuration: configuration + ) + let probe = BrowserOwnedDocumentNavigationProbe(finished: loaded) + webView.navigationDelegate = probe + webView.loadHTMLString( + "
Document
", + baseURL: nil + ) + + wait(for: [loaded], timeout: 5) + XCTAssertNil(probe.failure) + var initialHeroTop = 0.0 + webView.evaluateJavaScript(Self.snapshotScript) { value, error in + XCTAssertNil(error) + do { + let snapshot = try Self.decodeSnapshot(value) + initialHeroTop = try XCTUnwrap(snapshot["heroTop"] as? NSNumber).doubleValue + XCTAssertEqual(snapshot["firstBodyChildId"] as? String, "hero") + XCTAssertEqual(snapshot["spacerCount"] as? Int, 1) + XCTAssertEqual(snapshot["spacerDisplay"] as? String, "none") + XCTAssertEqual(snapshot["spacerHeight"] as? String, "0px") + XCTAssertEqual(snapshot["spacerIsLast"] as? Bool, true) + } catch { + XCTFail("Could not decode initial developer-tools layout: \(error)") + } + initialLayout.fulfill() + } + wait(for: [initialLayout], timeout: 5) + + webView.evaluateJavaScript( + BrowserDeveloperToolsScript.visibilityCommand(isVisible: true) + ) { accepted, error in + XCTAssertNil(error) + XCTAssertEqual(accepted as? Bool, true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + webView.evaluateJavaScript(Self.snapshotScript) { value, error in + XCTAssertNil(error) + do { + let snapshot = try Self.decodeSnapshot(value) + XCTAssertEqual(snapshot["initCount"] as? Int, 1) + XCTAssertEqual(snapshot["entryHidden"] as? Bool, true) + XCTAssertEqual(snapshot["visible"] as? Bool, true) + XCTAssertEqual(snapshot["displaySize"] as? Int, 45) + XCTAssertEqual(snapshot["theme"] as? String, "System preference") + XCTAssertNotEqual(snapshot["spacerHeight"] as? String, "0px") + XCTAssertEqual(snapshot["spacerDisplay"] as? String, "block") + XCTAssertEqual(snapshot["spacerCount"] as? Int, 1) + XCTAssertEqual(snapshot["spacerIsLast"] as? Bool, true) + XCTAssertEqual(snapshot["firstBodyChildId"] as? String, "hero") + XCTAssertEqual( + try XCTUnwrap(snapshot["heroTop"] as? NSNumber).doubleValue, + initialHeroTop, + accuracy: 0.1 + ) + } catch { + XCTFail("Could not decode developer-tools snapshot: \(error)") + } + shown.fulfill() + } + } + } + wait(for: [shown], timeout: 5) + + webView.evaluateJavaScript(userScript.source) { _, error in + XCTAssertNil(error) + webView.evaluateJavaScript( + BrowserDeveloperToolsScript.visibilityCommand(isVisible: false) + ) { accepted, error in + XCTAssertNil(error) + XCTAssertEqual(accepted as? Bool, true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + webView.evaluateJavaScript(Self.snapshotScript) { value, error in + XCTAssertNil(error) + do { + let snapshot = try Self.decodeSnapshot(value) + XCTAssertEqual(snapshot["initCount"] as? Int, 1) + XCTAssertEqual(snapshot["visible"] as? Bool, false) + XCTAssertEqual(snapshot["spacerHeight"] as? String, "0px") + XCTAssertEqual(snapshot["spacerDisplay"] as? String, "none") + XCTAssertEqual(snapshot["spacerCount"] as? Int, 1) + XCTAssertEqual(snapshot["spacerIsLast"] as? Bool, true) + XCTAssertEqual(snapshot["firstBodyChildId"] as? String, "hero") + XCTAssertEqual( + try XCTUnwrap(snapshot["heroTop"] as? NSNumber).doubleValue, + initialHeroTop, + accuracy: 0.1 + ) + } catch { + XCTFail("Could not decode developer-tools snapshot: \(error)") + } + hidden.fulfill() + } + } + } + } + wait(for: [hidden], timeout: 5) + } + + func testVisibleSpacerReattachesAfterBodyContentAndBodyReplacement() throws { + let loaded = expectation(description: "document loads for spacer replacement") + let verified = expectation(description: "spacer follows replacement content") + let configuration = WKWebViewConfiguration() + configuration.userContentController.addUserScript( + BrowserDeveloperToolsScript.makeUserScript(erudaSource: Self.stubErudaRuntime) + ) + let webView = WKWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 500), + configuration: configuration + ) + let probe = BrowserOwnedDocumentNavigationProbe(finished: loaded) + webView.navigationDelegate = probe + webView.loadHTMLString( + "
Original
", + baseURL: nil + ) + + wait(for: [loaded], timeout: 5) + XCTAssertNil(probe.failure) + webView.evaluateJavaScript( + """ + (function(){ + window.__shellularSetDeveloperToolsVisible(true); + var lateContent = document.createElement("section"); + lateContent.id = "late-content"; + document.body.appendChild(lateContent); + window.__shellularSetDeveloperToolsVisible(true); + var spacer = document.getElementById("__shellularDeveloperToolsSpacer"); + var afterAppend = { + count: document.querySelectorAll("#__shellularDeveloperToolsSpacer").length, + isLast: document.body.lastElementChild === spacer, + lateContentPrecedesSpacer: lateContent.nextElementSibling === spacer + }; + + var replacement = document.createElement("body"); + replacement.innerHTML = '
Replacement
'; + document.body.replaceWith(replacement); + window.__shellularSetDeveloperToolsVisible(true); + spacer = document.getElementById("__shellularDeveloperToolsSpacer"); + return JSON.stringify({ + afterAppend: afterAppend, + afterReplacement: { + count: document.querySelectorAll("#__shellularDeveloperToolsSpacer").length, + isLast: document.body.lastElementChild === spacer, + firstBodyChildId: document.body.firstElementChild.id, + display: spacer.style.display + } + }); + })(); + """ + ) { value, error in + XCTAssertNil(error) + do { + let result = try Self.decodeSnapshot(value) + let afterAppend = try XCTUnwrap(result["afterAppend"] as? [String: Any]) + XCTAssertEqual(afterAppend["count"] as? Int, 1) + XCTAssertEqual(afterAppend["isLast"] as? Bool, true) + XCTAssertEqual(afterAppend["lateContentPrecedesSpacer"] as? Bool, true) + + let afterReplacement = try XCTUnwrap( + result["afterReplacement"] as? [String: Any] + ) + XCTAssertEqual(afterReplacement["count"] as? Int, 1) + XCTAssertEqual(afterReplacement["isLast"] as? Bool, true) + XCTAssertEqual(afterReplacement["firstBodyChildId"] as? String, "replacement-hero") + XCTAssertEqual(afterReplacement["display"] as? String, "block") + } catch { + XCTFail("Could not decode replacement spacer layout: \(error)") + } + verified.fulfill() + } + wait(for: [verified], timeout: 5) + } + + func testSpacerIsCreatedOnceAtDocumentEndAcrossNavigations() throws { + let configuration = WKWebViewConfiguration() + configuration.userContentController.addUserScript( + BrowserDeveloperToolsScript.makeUserScript(erudaSource: Self.stubErudaRuntime) + ) + let webView = WKWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 500), + configuration: configuration + ) + + for pageID in ["first-page", "second-page"] { + let loaded = expectation(description: "\(pageID) loads") + let verified = expectation(description: "\(pageID) spacer is verified") + let probe = BrowserOwnedDocumentNavigationProbe(finished: loaded) + webView.navigationDelegate = probe + webView.loadHTMLString( + "
Document
", + baseURL: nil + ) + + wait(for: [loaded], timeout: 5) + XCTAssertNil(probe.failure) + webView.evaluateJavaScript( + BrowserDeveloperToolsScript.visibilityCommand(isVisible: true) + ) { accepted, error in + XCTAssertNil(error) + XCTAssertEqual(accepted as? Bool, true) + webView.evaluateJavaScript(Self.snapshotScript) { value, error in + XCTAssertNil(error) + do { + let snapshot = try Self.decodeSnapshot(value) + XCTAssertEqual(snapshot["initCount"] as? Int, 1) + XCTAssertEqual(snapshot["spacerCount"] as? Int, 1) + XCTAssertEqual(snapshot["spacerIsLast"] as? Bool, true) + XCTAssertEqual(snapshot["firstBodyChildId"] as? String, pageID) + } catch { + XCTFail("Could not decode \(pageID) spacer layout: \(error)") + } + verified.fulfill() + } + } + wait(for: [verified], timeout: 5) + } + } + + func testInspectCommandOpensElementsAndSelectsContextTarget() { + let loaded = expectation(description: "document loads for element inspection") + let inspected = expectation(description: "context target is inspected") + let cleared = expectation(description: "element highlight is cleared") + let closed = expectation(description: "closing tools clears the element highlight") + let configuration = WKWebViewConfiguration() + configuration.userContentController.addUserScript( + BrowserDeveloperToolsScript.makeUserScript(erudaSource: Self.stubErudaRuntime) + ) + let webView = WKWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 500), + configuration: configuration + ) + let probe = BrowserOwnedDocumentNavigationProbe(finished: loaded) + webView.navigationDelegate = probe + webView.loadHTMLString( + "", + baseURL: nil + ) + + wait(for: [loaded], timeout: 5) + XCTAssertNil(probe.failure) + webView.evaluateJavaScript( + "document.getElementById('inspect-me').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true }));" + ) { _, error in + XCTAssertNil(error) + webView.evaluateJavaScript( + BrowserDeveloperToolsScript.inspectElementCommand() + ) { accepted, error in + XCTAssertNil(error) + XCTAssertEqual(accepted as? Bool, true) + webView.evaluateJavaScript(Self.snapshotScript) { value, error in + XCTAssertNil(error) + do { + let snapshot = try Self.decodeSnapshot(value) + XCTAssertEqual(snapshot["initCount"] as? Int, 1) + XCTAssertEqual(snapshot["visible"] as? Bool, true) + XCTAssertEqual(snapshot["shownTool"] as? String, "elements") + XCTAssertEqual(snapshot["revealCount"] as? Int, 1) + XCTAssertEqual(snapshot["selectedElementId"] as? String, "inspect-me") + XCTAssertEqual(snapshot["elementHighlighted"] as? Bool, true) + } catch { + XCTFail("Could not decode inspection snapshot: \(error)") + } + inspected.fulfill() + } + } + } + wait(for: [inspected], timeout: 5) + + webView.evaluateJavaScript( + BrowserDeveloperToolsScript.clearElementHighlightCommand() + ) { accepted, error in + XCTAssertNil(error) + XCTAssertEqual(accepted as? Bool, true) + webView.evaluateJavaScript(Self.snapshotScript) { value, error in + XCTAssertNil(error) + do { + let snapshot = try Self.decodeSnapshot(value) + XCTAssertEqual(snapshot["visible"] as? Bool, true) + XCTAssertEqual(snapshot["elementHighlighted"] as? Bool, false) + XCTAssertEqual(snapshot["highlightClearCount"] as? Int, 1) + } catch { + XCTFail("Could not decode cleared inspection snapshot: \(error)") + } + cleared.fulfill() + } + } + wait(for: [cleared], timeout: 5) + + webView.evaluateJavaScript( + BrowserDeveloperToolsScript.inspectElementCommand() + ) { accepted, error in + XCTAssertNil(error) + XCTAssertEqual(accepted as? Bool, true) + webView.evaluateJavaScript( + BrowserDeveloperToolsScript.visibilityCommand(isVisible: false) + ) { accepted, error in + XCTAssertNil(error) + XCTAssertEqual(accepted as? Bool, true) + webView.evaluateJavaScript(Self.snapshotScript) { value, error in + XCTAssertNil(error) + do { + let snapshot = try Self.decodeSnapshot(value) + XCTAssertEqual(snapshot["visible"] as? Bool, false) + XCTAssertEqual(snapshot["elementHighlighted"] as? Bool, false) + XCTAssertEqual(snapshot["highlightClearCount"] as? Int, 2) + } catch { + XCTFail("Could not decode closed inspection snapshot: \(error)") + } + closed.fulfill() + } + } + } + wait(for: [closed], timeout: 5) + } + + func testBundledErudaRuntimeBootsInDocumentPage() throws { + let testFile = URL(fileURLWithPath: #filePath) + let macOSDirectory = testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let runtime = try XCTUnwrap( + BrowserDeveloperToolsScript.loadRuntime( + resourceURL: macOSDirectory.appendingPathComponent("shellular", isDirectory: true) + ) + ) + let loaded = expectation(description: "document loads with bundled Eruda") + let inspected = expectation(description: "bundled Eruda initializes") + let configuration = WKWebViewConfiguration() + configuration.userContentController.addUserScript( + BrowserDeveloperToolsScript.makeUserScript(erudaSource: runtime) + ) + let webView = WKWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 500), + configuration: configuration + ) + let probe = BrowserOwnedDocumentNavigationProbe(finished: loaded) + webView.navigationDelegate = probe + webView.loadHTMLString("
Document
", baseURL: nil) + + wait(for: [loaded], timeout: 5) + XCTAssertNil(probe.failure) + let stateScript = """ + JSON.stringify({ + ready: window.__shellularDeveloperTools?.getState().ready === true, + version: window.eruda?.version || "", + selectType: typeof window.eruda?.get("elements")?.select, + detailHideType: typeof window.eruda?.get("elements")?._detail?.hide, + clearHighlightType: typeof window.__shellularClearElementHighlight + }) + """ + func inspectRuntime(remainingAttempts: Int) { + webView.evaluateJavaScript(stateScript) { value, error in + do { + XCTAssertNil(error) + let result = try Self.decodeSnapshot(value) + if result["ready"] as? Bool != true, remainingAttempts > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + inspectRuntime(remainingAttempts: remainingAttempts - 1) + } + return + } + if result["ready"] as? Bool != true { + webView.evaluateJavaScript(runtime) { _, runtimeError in + XCTFail( + "Bundled console.js did not install its adapter: \(String(describing: runtimeError))" + ) + inspected.fulfill() + } + return + } + XCTAssertEqual(result["ready"] as? Bool, true, String(describing: result)) + XCTAssertFalse((result["version"] as? String ?? "").isEmpty) + XCTAssertEqual(result["selectType"] as? String, "function") + XCTAssertEqual(result["detailHideType"] as? String, "function") + XCTAssertEqual(result["clearHighlightType"] as? String, "function") + } catch { + XCTFail("Could not decode bundled developer-tools state: \(error)") + } + inspected.fulfill() + } + } + inspectRuntime(remainingAttempts: 20) + wait(for: [inspected], timeout: 5) + } + + func testBundledErudaInspectShowsVisibleElementsPanel() throws { + let testFile = URL(fileURLWithPath: #filePath) + let macOSDirectory = testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let runtime = try XCTUnwrap( + BrowserDeveloperToolsScript.loadRuntime( + resourceURL: macOSDirectory.appendingPathComponent("shellular", isDirectory: true) + ) + ) + let loaded = expectation(description: "document loads with bundled Eruda") + let panelShown = expectation(description: "bundled Eruda Elements panel becomes visible") + let configuration = WKWebViewConfiguration() + configuration.userContentController.addUserScript( + BrowserDeveloperToolsScript.makeUserScript(erudaSource: runtime) + ) + let webView = WKWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 500), + configuration: configuration + ) + let hostWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 500), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + hostWindow.contentView = webView + hostWindow.makeKeyAndOrderFront(nil) + defer { hostWindow.orderOut(nil) } + let probe = BrowserOwnedDocumentNavigationProbe(finished: loaded) + webView.navigationDelegate = probe + webView.loadHTMLString( + "", + baseURL: nil + ) + + wait(for: [loaded], timeout: 5) + XCTAssertNil(probe.failure) + + func showPanel(remainingAttempts: Int) { + webView.evaluateJavaScript( + "window.__shellularDeveloperTools?.getState().ready === true" + ) { ready, error in + XCTAssertNil(error) + if ready as? Bool != true, remainingAttempts > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + showPanel(remainingAttempts: remainingAttempts - 1) + } + return + } + XCTAssertEqual(ready as? Bool, true) + let inspectScript = """ + document.getElementById("inspect-me") + .dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + \(BrowserDeveloperToolsScript.inspectElementCommand()) + """ + webView.evaluateJavaScript(inspectScript) { accepted, error in + XCTAssertNil(error) + XCTAssertEqual(accepted as? Bool, true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + let visibilityScript = """ + (function(){ + var panel = window.eruda?._shadowRoot?.querySelector(".eruda-dev-tools"); + var style = panel && window.getComputedStyle(panel); + return JSON.stringify({ + visible: window.__shellularDeveloperTools?.getState().visible === true, + highlighted: window.__shellularDeveloperTools?.getState().highlighted === true, + internalVisible: window.eruda?._devTools?._isShow === true, + currentTool: window.eruda?._devTools?._curTool || "", + selectedElementId: window.eruda?.get("elements")?._curNode?.id || "", + panelFound: !!panel, + display: style?.display || "", + visibility: style?.visibility || "", + inlineOpacity: Number(panel?.style.opacity || 0), + height: panel?.getBoundingClientRect().height || 0 + }); + })(); + """ + webView.evaluateJavaScript(visibilityScript) { value, error in + XCTAssertNil(error) + do { + let result = try Self.decodeSnapshot(value) + XCTAssertEqual(result["visible"] as? Bool, true, String(describing: result)) + XCTAssertEqual(result["highlighted"] as? Bool, true, String(describing: result)) + XCTAssertEqual(result["internalVisible"] as? Bool, true, String(describing: result)) + XCTAssertEqual(result["currentTool"] as? String, "elements", String(describing: result)) + XCTAssertEqual(result["selectedElementId"] as? String, "inspect-me", String(describing: result)) + XCTAssertEqual(result["panelFound"] as? Bool, true, String(describing: result)) + XCTAssertNotEqual(result["display"] as? String, "none", String(describing: result)) + XCTAssertNotEqual(result["visibility"] as? String, "hidden", String(describing: result)) + XCTAssertGreaterThan( + (result["inlineOpacity"] as? NSNumber)?.doubleValue ?? 0, + 0, + String(describing: result) + ) + XCTAssertGreaterThan( + (result["height"] as? NSNumber)?.doubleValue ?? 0, + 0, + String(describing: result) + ) + } catch { + XCTFail("Could not inspect bundled developer-tools panel: \(error)") + } + panelShown.fulfill() + } + } + } + } + } + showPanel(remainingAttempts: 20) + wait(for: [panelShown], timeout: 5) + } + + func testBundledErudaReadinessMessagePassesNativeValidation() throws { + let testFile = URL(fileURLWithPath: #filePath) + let macOSDirectory = testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let runtime = try XCTUnwrap( + BrowserDeveloperToolsScript.loadRuntime( + resourceURL: macOSDirectory.appendingPathComponent("shellular", isDirectory: true) + ) + ) + let readyMessage = expectation(description: "native receives validated readiness message") + let configuration = WKWebViewConfiguration() + let sink = BrowserScriptMessageSink() + sink.onMessage = { message in + guard let snapshot = BrowserDeveloperToolsSnapshot(payload: message.body), + snapshot.ready else { return } + XCTAssertFalse(snapshot.isVisible) + XCTAssertEqual(snapshot.panelPercent, 45) + readyMessage.fulfill() + } + configuration.userContentController.add( + sink, + name: BrowserDeveloperToolsScript.messageHandlerName + ) + configuration.userContentController.addUserScript( + BrowserDeveloperToolsScript.makeUserScript(erudaSource: runtime) + ) + let webView = WKWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 500), + configuration: configuration + ) + webView.loadHTMLString("
Document
", baseURL: nil) + + wait(for: [readyMessage], timeout: 5) + withExtendedLifetime(webView) {} + } + + private static let snapshotScript = """ + JSON.stringify({ + initCount: window.__stubErudaInitCount, + entryHidden: window.__stubErudaEntryHidden === true, + visible: window.__stubErudaVisible === true, + displaySize: window.__stubErudaOptions.defaults.displaySize, + theme: window.__stubErudaOptions.defaults.theme, + shownTool: window.__stubErudaShownTool, + revealCount: window.__stubErudaRevealCount || 0, + selectedElementId: window.__stubErudaSelectedElementId, + elementHighlighted: window.__stubErudaElementHighlighted === true, + highlightClearCount: window.__stubErudaHighlightClearCount || 0, + spacerHeight: document.getElementById("__shellularDeveloperToolsSpacer").style.height, + spacerDisplay: document.getElementById("__shellularDeveloperToolsSpacer").style.display, + spacerCount: document.querySelectorAll("#__shellularDeveloperToolsSpacer").length, + spacerIsLast: document.body.lastElementChild === document.getElementById("__shellularDeveloperToolsSpacer"), + firstBodyChildId: document.body.firstElementChild && document.body.firstElementChild.id, + heroTop: document.getElementById("hero") && document.getElementById("hero").getBoundingClientRect().top + }); + """ + + private static func decodeSnapshot(_ value: Any?) throws -> [String: Any] { + let json = try XCTUnwrap(value as? String) + let data = try XCTUnwrap(json.data(using: .utf8)) + return try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + } + + private func makeTemporaryResourcesDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + addTeardownBlock { + try? FileManager.default.removeItem(at: directory) + } + return directory + } + + private func keyEvent( + _ characters: String, + _ modifiers: NSEvent.ModifierFlags + ) throws -> NSEvent { + try XCTUnwrap( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: 0 + ) + ) + } +} + +private final class BrowserScriptMessageSink: NSObject, WKScriptMessageHandler { + var onMessage: ((WKScriptMessage) -> Void)? + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + onMessage?(message) + } +} + +final class BrowserDeveloperToolsStateTests: XCTestCase { + func testSnapshotValidationAcceptsOnlyTheVersionedNarrowPayload() throws { + let snapshot = try XCTUnwrap(BrowserDeveloperToolsSnapshot(payload: [ + "version": 1, + "ready": true, + "visible": true, + "panelPercent": 62.5, + "highlighted": true, + ])) + + XCTAssertTrue(snapshot.ready) + XCTAssertTrue(snapshot.isVisible) + XCTAssertEqual(snapshot.panelPercent, 62.5) + XCTAssertTrue(snapshot.isElementHighlighted) + XCTAssertNil(BrowserDeveloperToolsSnapshot(payload: [ + "version": 2, + "ready": true, + "visible": false, + "panelPercent": 45, + "highlighted": false, + ])) + XCTAssertNil(BrowserDeveloperToolsSnapshot(payload: [ + "version": 1, + "ready": true, + "visible": false, + "panelPercent": "45", + "highlighted": false, + ])) + XCTAssertNil(BrowserDeveloperToolsSnapshot(payload: [ + "version": 1, + "ready": true, + "visible": false, + "panelPercent": 45, + "highlighted": false, + "command": "navigate", + ])) + } + + func testSnapshotAndStateClampPanelSizeAndPreserveFirstLoadPreferences() throws { + let snapshot = try XCTUnwrap(BrowserDeveloperToolsSnapshot(payload: [ + "version": 1, + "ready": true, + "visible": false, + "panelPercent": 100, + "highlighted": false, + ])) + XCTAssertEqual(snapshot.panelPercent, 70) + + var state = BrowserDeveloperToolsState(panelPercent: 55) + state.show() + state.apply( + snapshot, + preserveVisibility: true, + preservePanelPercent: true + ) + XCTAssertTrue(state.isReady) + XCTAssertTrue(state.isVisible) + XCTAssertEqual(state.panelPercent, 55) + + state.apply(snapshot) + XCTAssertFalse(state.isVisible) + XCTAssertEqual(state.panelPercent, 70) + state.setPanelPercent(10) + XCTAssertEqual(state.panelPercent, 25) + state.setPanelPercent(.infinity) + XCTAssertEqual(state.panelPercent, 45) + } + + func testVisibilityCanBeRequestedWhileRuntimeLoadsButNotAfterFailure() throws { + var state = BrowserDeveloperToolsState() + XCTAssertTrue( + BrowserDeveloperToolsAvailability.canRequestVisibility( + hasRuntime: true, + state: state + ) + ) + XCTAssertFalse( + BrowserDeveloperToolsAvailability.canRequestVisibility( + hasRuntime: false, + state: state + ) + ) + + let failed = try XCTUnwrap(BrowserDeveloperToolsSnapshot(payload: [ + "version": 1, + "ready": false, + "visible": false, + "panelPercent": 45, + "highlighted": false, + ])) + state.apply(failed) + XCTAssertFalse( + BrowserDeveloperToolsAvailability.canRequestVisibility( + hasRuntime: true, + state: state + ) + ) + } + + func testPanelPreferencePersistsGloballyWithDefaultAndClamping() throws { + let suiteName = "BrowserDeveloperToolsStateTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = BrowserDeveloperToolsPanelSizeStore( + defaults: defaults, + key: "panel" + ) + + XCTAssertEqual(store.load(), 45) + store.save(63) + XCTAssertEqual(store.load(), 63) + store.save(90) + XCTAssertEqual(store.load(), 70) + store.save(5) + XCTAssertEqual(store.load(), 25) + } + + func testMessageProxyDoesNotRetainItsDelegate() { + let proxy = BrowserWeakScriptMessageHandler() + weak var releasedDelegate: BrowserScriptMessageSink? + autoreleasepool { + let delegate = BrowserScriptMessageSink() + releasedDelegate = delegate + proxy.delegate = delegate + XCTAssertNotNil(releasedDelegate) + } + XCTAssertNil(releasedDelegate) + XCTAssertNil(proxy.delegate) + } +} + +final class BrowserViewportTests: XCTestCase { + func testFitUsesTheEntireAvailableViewport() { + let layout = BrowserViewportMetrics.layout( + in: CGSize(width: 1_120, height: 680), + mode: .fit + ) + XCTAssertEqual(layout.documentSize, CGSize(width: 1_120, height: 680)) + XCTAssertEqual(layout.pageFrame, CGRect(x: 0, y: 0, width: 1_120, height: 680)) + XCTAssertFalse(layout.allowsHorizontalScrolling) + } + + func testFixedPhoneAndRestoredWindowViewportsAreCenteredWithoutScaling() { + let phone = BrowserViewportMetrics.layout( + in: CGSize(width: 1_120, height: 680), + mode: .phone + ) + XCTAssertEqual(phone.pageFrame, CGRect(x: 365, y: 0, width: 390, height: 680)) + XCTAssertFalse(phone.allowsHorizontalScrolling) + + let tablet = BrowserViewportMetrics.layout( + in: CGSize(width: 1_120, height: 680), + mode: .tablet + ) + XCTAssertEqual(tablet.pageFrame, CGRect(x: 176, y: 0, width: 768, height: 680)) + XCTAssertFalse(tablet.allowsHorizontalScrolling) + } + + func testConstrainedSidebarKeepsTrueCSSWidthAndScrollsHorizontally() { + let layout = BrowserViewportMetrics.layout( + in: CGSize(width: 360, height: 720), + mode: .tablet + ) + XCTAssertEqual(layout.documentSize, CGSize(width: 768, height: 720)) + XCTAssertEqual(layout.pageFrame, CGRect(x: 0, y: 0, width: 768, height: 720)) + XCTAssertTrue(layout.allowsHorizontalScrolling) + } + + func testCustomViewportWidthsAreNormalizedToSupportedRange() { + XCTAssertEqual(BrowserViewportMode.fixed(200).normalized, .fixed(320)) + XCTAssertEqual(BrowserViewportMode.fixed(2_500).normalized, .fixed(1_920)) + XCTAssertEqual(BrowserViewportMode.fixed(.nan).normalized, .fit) + } +} + +final class BrowserScreenshotTests: XCTestCase { + func testSnapshotGeometryExcludesOnlyTheVisibleDiagnosticsPanel() { + let bounds = CGRect(x: 0, y: 0, width: 390, height: 800) + XCTAssertEqual( + BrowserScreenshotMetrics.visiblePageRect( + in: bounds, + developerToolsVisible: false, + panelPercent: 70 + ), + bounds + ) + let diagnosticsExcluded = BrowserScreenshotMetrics.visiblePageRect( + in: bounds, + developerToolsVisible: true, + panelPercent: 45 + ) + XCTAssertEqual(diagnosticsExcluded.width, 390, accuracy: 0.001) + XCTAssertEqual(diagnosticsExcluded.height, 440, accuracy: 0.001) + XCTAssertEqual( + BrowserScreenshotMetrics.visiblePageRect( + in: bounds, + developerToolsVisible: true, + panelPercent: 90 + ).height, + 240, + accuracy: 0.001 + ) + } + + func testPNGEncodingPreservesPixelDimensionsAndReportsFailure() throws { + let bitmap = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: 120, + pixelsHigh: 80, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bitmapFormat: [], + bytesPerRow: 0, + bitsPerPixel: 0 + )) + let image = NSImage(size: NSSize(width: 120, height: 80)) + image.addRepresentation(bitmap) + let data = try XCTUnwrap(BrowserScreenshotEncoder.pngData(from: image)) + let encoded = try XCTUnwrap(NSBitmapImageRep(data: data)) + XCTAssertEqual(encoded.pixelsWide, 120) + XCTAssertEqual(encoded.pixelsHigh, 80) + XCTAssertNil(BrowserScreenshotEncoder.pngData(from: NSImage(size: .zero))) + } + + func testSnapshotDimensionsMatchTheDiagnosticsExcludedVisibleArea() throws { + let loaded = expectation(description: "screenshot page loads") + let captured = expectation(description: "visible page area is captured") + let webView = WKWebView( + frame: NSRect(x: 0, y: 0, width: 390, height: 800), + configuration: WKWebViewConfiguration() + ) + let probe = BrowserOwnedDocumentNavigationProbe(finished: loaded) + webView.navigationDelegate = probe + webView.loadHTMLString( + "", + baseURL: nil + ) + wait(for: [loaded], timeout: 5) + XCTAssertNil(probe.failure) + + let configuration = WKSnapshotConfiguration() + configuration.rect = BrowserScreenshotMetrics.visiblePageRect( + in: webView.bounds, + developerToolsVisible: true, + panelPercent: 45 + ) + webView.takeSnapshot(with: configuration) { image, error in + XCTAssertNil(error) + XCTAssertEqual(image?.size.width ?? 0, 390, accuracy: 0.001) + XCTAssertEqual(image?.size.height ?? 0, 440, accuracy: 0.001) + XCTAssertNotNil(image.flatMap { BrowserScreenshotEncoder.pngData(from: $0) }) + captured.fulfill() + } + wait(for: [captured], timeout: 5) + } + + func testSavePanelCancellationDoesNotProduceADestination() { + let url = URL(fileURLWithPath: "/tmp/screenshot.png") + XCTAssertNil(BrowserScreenshotSaveDecision.destination( + for: .cancel, + selectedURL: url + )) + XCTAssertNil(BrowserScreenshotSaveDecision.destination( + for: .OK, + selectedURL: nil + )) + XCTAssertEqual( + BrowserScreenshotSaveDecision.destination(for: .OK, selectedURL: url), + url + ) + } +} + +final class BrowserDeveloperToolsContextMenuTests: XCTestCase { + func testWebViewUsesOneDynamicMenuItemForInspectAndCloseActions() throws { + let webView = BrowserInspectableWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 400), + configuration: WKWebViewConfiguration() + ) + webView.embeddedDeveloperToolsAvailable = true + var isVisible = false + var isElementHighlighted = false + var inspectCount = 0 + var clearCount = 0 + var closeCount = 0 + webView.developerToolsVisibility = { isVisible } + webView.elementHighlightVisibility = { isElementHighlighted } + webView.onInspectElement = { + inspectCount += 1 + isVisible = true + isElementHighlighted = true + } + webView.onClearElementHighlight = { + clearCount += 1 + isElementHighlighted = false + } + webView.onCloseDeveloperTools = { + closeCount += 1 + isVisible = false + } + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Reload", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Go Back", action: nil, keyEquivalent: "")) + let event = try rightMouseEvent() + + webView.willOpenMenu(menu, with: event) + webView.willOpenMenu(menu, with: event) + + XCTAssertEqual(menu.items.prefix(2).map(\.title), ["Reload", "Go Back"]) + let developerToolsItems = menu.items.filter { + $0.identifier == BrowserDeveloperToolsContextMenu.developerToolsIdentifier + } + let item = try XCTUnwrap(developerToolsItems.first) + XCTAssertEqual(developerToolsItems.count, 1) + XCTAssertEqual(item.title, BrowserDeveloperToolsContextMenu.inspectElementTitle) + XCTAssertTrue(item.isEnabled) + let action = try XCTUnwrap(item.action) + XCTAssertTrue(NSApplication.shared.sendAction( + action, + to: item.target, + from: item + )) + XCTAssertEqual(inspectCount, 1) + XCTAssertEqual(closeCount, 0) + + webView.willOpenMenu(menu, with: event) + + XCTAssertEqual( + menu.items.filter { + $0.identifier == BrowserDeveloperToolsContextMenu.developerToolsIdentifier + }.count, + 1 + ) + XCTAssertTrue(menu.items.first { + $0.identifier == BrowserDeveloperToolsContextMenu.developerToolsIdentifier + } === item) + XCTAssertEqual(item.title, BrowserDeveloperToolsContextMenu.closeDeveloperToolsTitle) + let clearItem = try XCTUnwrap(menu.items.first { + $0.identifier == BrowserDeveloperToolsContextMenu.clearElementHighlightIdentifier + }) + XCTAssertEqual(clearItem.title, BrowserDeveloperToolsContextMenu.clearElementHighlightTitle) + XCTAssertTrue(NSApplication.shared.sendAction( + try XCTUnwrap(clearItem.action), + to: clearItem.target, + from: clearItem + )) + XCTAssertEqual(clearCount, 1) + + webView.willOpenMenu(menu, with: event) + + XCTAssertFalse(menu.items.contains { + $0.identifier == BrowserDeveloperToolsContextMenu.clearElementHighlightIdentifier + }) + XCTAssertTrue(NSApplication.shared.sendAction( + action, + to: item.target, + from: item + )) + XCTAssertEqual(inspectCount, 1) + XCTAssertEqual(closeCount, 1) + + webView.willOpenMenu(menu, with: event) + + XCTAssertEqual(item.title, BrowserDeveloperToolsContextMenu.inspectElementTitle) + } + + func testWebViewMenuForEventFallbackAddsOneInspectItem() throws { + let webView = BrowserInspectableWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 400), + configuration: WKWebViewConfiguration() + ) + webView.embeddedDeveloperToolsAvailable = true + + let menu = try XCTUnwrap(webView.menu(for: try rightMouseEvent())) + + XCTAssertEqual( + menu.items.filter { + $0.identifier == BrowserDeveloperToolsContextMenu.developerToolsIdentifier + }.count, + 1 + ) + XCTAssertEqual( + menu.items.first { + $0.identifier == BrowserDeveloperToolsContextMenu.developerToolsIdentifier + }?.title, + BrowserDeveloperToolsContextMenu.inspectElementTitle + ) + } + + func testAddsOneDynamicItemWithoutRemovingExistingMenuActions() { + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Reload", action: nil, keyEquivalent: "")) + let target = BrowserInspectMenuTarget() + + let item = BrowserDeveloperToolsContextMenu.addDeveloperToolsItems( + to: menu, + target: target, + developerToolsAction: #selector(BrowserInspectMenuTarget.inspectElement(_:)), + clearHighlightAction: #selector(BrowserInspectMenuTarget.inspectElement(_:)), + isEnabled: true, + isVisible: false, + isElementHighlighted: false + ) + BrowserDeveloperToolsContextMenu.addDeveloperToolsItems( + to: menu, + target: target, + developerToolsAction: #selector(BrowserInspectMenuTarget.inspectElement(_:)), + clearHighlightAction: #selector(BrowserInspectMenuTarget.inspectElement(_:)), + isEnabled: true, + isVisible: true, + isElementHighlighted: true + ) + + XCTAssertEqual(menu.items.first?.title, "Reload") + XCTAssertEqual( + menu.items.filter { + $0.identifier == BrowserDeveloperToolsContextMenu.developerToolsIdentifier + }.count, + 1 + ) + XCTAssertEqual(item.title, BrowserDeveloperToolsContextMenu.closeDeveloperToolsTitle) + XCTAssertEqual( + menu.items.filter { + $0.identifier == BrowserDeveloperToolsContextMenu.clearElementHighlightIdentifier + }.count, + 1 + ) + XCTAssertTrue(menu.items.contains(where: \.isSeparatorItem)) + XCTAssertTrue(item.target === target) + XCTAssertEqual(item.action, #selector(BrowserInspectMenuTarget.inspectElement(_:))) + XCTAssertTrue(item.isEnabled) + XCTAssertNil(item.toolTip) + + BrowserDeveloperToolsContextMenu.addDeveloperToolsItems( + to: menu, + target: target, + developerToolsAction: #selector(BrowserInspectMenuTarget.inspectElement(_:)), + clearHighlightAction: #selector(BrowserInspectMenuTarget.inspectElement(_:)), + isEnabled: true, + isVisible: true, + isElementHighlighted: false + ) + XCTAssertFalse(menu.items.contains { + $0.identifier == BrowserDeveloperToolsContextMenu.clearElementHighlightIdentifier + }) + } + + func testReusesAndDisablesAnExistingInspectElementItemWhenRuntimeIsMissing() { + let menu = NSMenu() + menu.addItem(NSMenuItem( + title: BrowserDeveloperToolsContextMenu.inspectElementTitle, + action: nil, + keyEquivalent: "" + )) + let target = BrowserInspectMenuTarget() + + let item = BrowserDeveloperToolsContextMenu.addDeveloperToolsItems( + to: menu, + target: target, + developerToolsAction: #selector(BrowserInspectMenuTarget.inspectElement(_:)), + clearHighlightAction: #selector(BrowserInspectMenuTarget.inspectElement(_:)), + isEnabled: false, + isVisible: false, + isElementHighlighted: false + ) + + XCTAssertEqual(menu.items.count, 1) + XCTAssertFalse(item.isEnabled) + XCTAssertNil(item.target) + XCTAssertNil(item.action) + XCTAssertEqual(item.toolTip, BrowserDeveloperToolsContextMenu.unavailableToolTip) + XCTAssertEqual(item.identifier, BrowserDeveloperToolsContextMenu.developerToolsIdentifier) + } + + func testDifferentWebViewsUseTheirIndependentVisibilityState() throws { + let closedWebView = makeWebView(isVisible: false, isElementHighlighted: false) + let openWebView = makeWebView(isVisible: true, isElementHighlighted: true) + let event = try rightMouseEvent() + let closedMenu = NSMenu() + let openMenu = NSMenu() + + closedWebView.willOpenMenu(closedMenu, with: event) + openWebView.willOpenMenu(openMenu, with: event) + + XCTAssertEqual( + closedMenu.items.first { + $0.identifier == BrowserDeveloperToolsContextMenu.developerToolsIdentifier + }?.title, + BrowserDeveloperToolsContextMenu.inspectElementTitle + ) + XCTAssertEqual( + openMenu.items.first { + $0.identifier == BrowserDeveloperToolsContextMenu.developerToolsIdentifier + }?.title, + BrowserDeveloperToolsContextMenu.closeDeveloperToolsTitle + ) + XCTAssertFalse(closedMenu.items.contains { + $0.identifier == BrowserDeveloperToolsContextMenu.clearElementHighlightIdentifier + }) + XCTAssertTrue(openMenu.items.contains { + $0.identifier == BrowserDeveloperToolsContextMenu.clearElementHighlightIdentifier + }) + } + + func testMenuActionRechecksLiveVisibilityBeforeDispatch() throws { + let webView = BrowserInspectableWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 400), + configuration: WKWebViewConfiguration() + ) + webView.embeddedDeveloperToolsAvailable = true + var isVisible = false + var inspectCount = 0 + var closeCount = 0 + webView.developerToolsVisibility = { isVisible } + webView.onInspectElement = { inspectCount += 1 } + webView.onCloseDeveloperTools = { closeCount += 1 } + let menu = NSMenu() + webView.willOpenMenu(menu, with: try rightMouseEvent()) + let item = try XCTUnwrap(menu.items.first { + $0.identifier == BrowserDeveloperToolsContextMenu.developerToolsIdentifier + }) + + isVisible = true + XCTAssertTrue(NSApplication.shared.sendAction( + try XCTUnwrap(item.action), + to: item.target, + from: item + )) + + XCTAssertEqual(inspectCount, 0) + XCTAssertEqual(closeCount, 1) + } + + private func makeWebView( + isVisible: Bool, + isElementHighlighted: Bool + ) -> BrowserInspectableWebView { + let webView = BrowserInspectableWebView( + frame: NSRect(x: 0, y: 0, width: 500, height: 400), + configuration: WKWebViewConfiguration() + ) + webView.embeddedDeveloperToolsAvailable = true + webView.developerToolsVisibility = { isVisible } + webView.elementHighlightVisibility = { isElementHighlighted } + return webView + } + + private func rightMouseEvent() throws -> NSEvent { + try XCTUnwrap(NSEvent.mouseEvent( + with: .rightMouseDown, + location: NSPoint(x: 100, y: 100), + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1 + )) + } +} + +final class BrowserChromeThemeTests: XCTestCase { + func testPaletteUsesShellularTokensAndSeparatorFallbacks() { + let palette = BrowserChromePalette([ + "primary": "#102030", + "surfaceSoft": "#20304080", + "primaryText": "#f0f1f2", + "secondaryText": "#a0a1a2", + "lineSoft": "#33445566", + "accent": "#4488ff", + "danger": "#dd3344", + ]) + + assertColor(palette.primary, red: 0x10, green: 0x20, blue: 0x30, alpha: 0xff) + assertColor(palette.surfaceSoft, red: 0x20, green: 0x30, blue: 0x40, alpha: 0x80) + assertColor(palette.separator, red: 0x33, green: 0x44, blue: 0x55, alpha: 0x66) + + let cardFallback = BrowserChromePalette(["cardBorder": "#556677"]) + assertColor(cardFallback.separator, red: 0x55, green: 0x66, blue: 0x77, alpha: 0xff) + } + + func testPaletteChromeBackgroundMatchesWorkbenchCSSMix() throws { + let palette = BrowserChromePalette([ + "primary": "#000000", + "secondary": "#ffffff", + ]) + let chrome = try XCTUnwrap(palette.chromeBackground.usingColorSpace(.sRGB)) + XCTAssertEqual(chrome.redComponent, 0.94, accuracy: 0.002) + XCTAssertEqual(chrome.greenComponent, 0.94, accuracy: 0.002) + XCTAssertEqual(chrome.blueComponent, 0.94, accuracy: 0.002) + + let fallback = BrowserChromePalette([ + "primary": "#000000", + "surfaceSoft": "#ffffff", + ]) + let fallbackChrome = try XCTUnwrap(fallback.chromeBackground.usingColorSpace(.sRGB)) + XCTAssertEqual(fallbackChrome.redComponent, 0.94, accuracy: 0.002) + } + + func testThemeScriptProducesSafeKebabCaseCSSVariables() throws { + let injection = #"";window.shellularThemeEscape=true;//"# + let variables = BrowserThemeScript.variables(from: [ + "primaryText": "#ffffff", + "surfaceSoft": "rgba(1, 2, 3, 0.5)", + "untrusted": injection, + "ignored": 42, + ]) + + XCTAssertEqual(variables["--primary-text"], "#ffffff") + XCTAssertEqual(variables["--surface-soft"], "rgba(1, 2, 3, 0.5)") + XCTAssertEqual(variables["--untrusted"], injection) + XCTAssertNil(variables["--ignored"]) + + let script = try XCTUnwrap(BrowserThemeScript.make(from: ["untrusted": injection])) + XCTAssertTrue(script.contains("root.style.setProperty")) + XCTAssertFalse(script.contains(#""--untrusted":"";window"#)) + } + + func testAddressBarUsesFocusAndValidationThemeStates() { + let palette = BrowserChromePalette([ + "primary": "#111111", + "surfaceSoft": "#222222", + "primaryText": "#eeeeee", + "secondaryText": "#aaaaaa", + "lineSoft": "#333333", + "accent": "#4488ff", + "danger": "#dd3344", + ]) + let addressBar = BrowserAddressBar(frame: NSRect(x: 0, y: 0, width: 320, height: 36)) + addressBar.applyPalette(palette) + addressBar.layoutSubtreeIfNeeded() + + XCTAssertFalse(addressBar.isFocused) + XCTAssertFalse(addressBar.hasValidationError) + XCTAssertFalse(addressBar.hasAmbiguousLayout) + XCTAssertGreaterThan(addressBar.textField.frame.width, 0) + + addressBar.setFocused(true) + XCTAssertTrue(addressBar.isFocused) + addressBar.setValidationError(true) + XCTAssertTrue(addressBar.hasValidationError) + XCTAssertEqual(addressBar.textField.textColor, palette.danger) + addressBar.setValidationError(false) + XCTAssertEqual(addressBar.textField.textColor, palette.primaryText) + } + + func testUnifiedTabHeaderFitsSidebarAndWindowPresentations() { + for width: CGFloat in [280, 400, 720] { + for presentation: BrowserPresentation in [.sidebar, .window] { + let header = BrowserTabHeaderView( + frame: NSRect(x: 0, y: 0, width: width, height: 40) + ) + for tabWidth: CGFloat in [90, 160, 220] { + let tab = NSView() + tab.translatesAutoresizingMaskIntoConstraints = false + tab.widthAnchor.constraint(equalToConstant: tabWidth).isActive = true + tab.heightAnchor.constraint(equalToConstant: 29).isActive = true + header.tabStack.addArrangedSubview(tab) + } + header.applyPresentation(presentation) + header.layoutSubtreeIfNeeded() + + XCTAssertFalse(header.hasAmbiguousLayout, "\(presentation), width: \(width)") + XCTAssertFalse(header.tabStack.hasAmbiguousLayout, "\(presentation), width: \(width)") + XCTAssertGreaterThan(header.tabStack.frame.width, 0) + XCTAssertEqual(header.closeButton.isHidden, presentation == .window) + for button in [ + header.addButton, + header.tabListButton, + header.presentationButton, + ] { + XCTAssertEqual(button.frame.width, 30, accuracy: 0.001) + XCTAssertLessThanOrEqual(button.frame.maxX, width) + } + } + } + } + + func testTabItemsRemainStableAcrossStateUpdatesAndDispatchActionsOnce() throws { + let header = BrowserTabHeaderView(frame: NSRect(x: 0, y: 0, width: 400, height: 40)) + let palette = BrowserChromePalette([ + "primary": "#101010", + "secondary": "#202020", + "surfaceSoft": "#303030", + "primaryText": "#ffffff", + "secondaryText": "#aaaaaa", + ]) + header.applyPalette(palette) + var selected: [String] = [] + var closed: [String] = [] + let select: (String) -> Void = { selected.append($0) } + let close: (String) -> Void = { closed.append($0) } + header.reconcileTabs([ + BrowserTabItemState(id: "first", title: "First", isSelected: true, statusColor: .systemGreen), + BrowserTabItemState(id: "second", title: "Second", isSelected: false, statusColor: .systemOrange), + ], onSelect: select, onClose: close) + header.layoutSubtreeIfNeeded() + + let first = try XCTUnwrap(header.tabItem(withID: "first")) + let second = try XCTUnwrap(header.tabItem(withID: "second")) + XCTAssertTrue(second.accessibilityPerformPress()) + second.closeButton.performClick(nil) + XCTAssertEqual(selected, ["second"]) + XCTAssertEqual(closed, ["second"]) + + header.reconcileTabs([ + BrowserTabItemState(id: "first", title: "Renamed", isSelected: false, statusColor: .systemOrange), + BrowserTabItemState(id: "second", title: "Second", isSelected: true, statusColor: .systemGreen), + ], onSelect: select, onClose: close) + header.layoutSubtreeIfNeeded() + + XCTAssertTrue(header.tabItem(withID: "first") === first) + XCTAssertTrue(header.tabItem(withID: "second") === second) + XCTAssertFalse(first.isSelected) + XCTAssertTrue(second.isSelected) + XCTAssertEqual(first.displayedTitle, "Renamed") + XCTAssertTrue(second.accessibilityPerformPress()) + XCTAssertEqual(selected, ["second", "second"]) + } + + func testTabReconciliationPreservesUnaffectedItemsAndOrdering() throws { + let header = BrowserTabHeaderView(frame: NSRect(x: 0, y: 0, width: 400, height: 40)) + let noAction: (String) -> Void = { _ in } + let state: (String) -> BrowserTabItemState = { + BrowserTabItemState(id: $0, title: $0, isSelected: $0 == "a", statusColor: .systemGreen) + } + header.reconcileTabs([state("a"), state("b"), state("c")], onSelect: noAction, onClose: noAction) + let a = try XCTUnwrap(header.tabItem(withID: "a")) + let c = try XCTUnwrap(header.tabItem(withID: "c")) + + header.reconcileTabs([state("c"), state("a"), state("d")], onSelect: noAction, onClose: noAction) + XCTAssertTrue(header.tabItem(withID: "a") === a) + XCTAssertTrue(header.tabItem(withID: "c") === c) + XCTAssertNil(header.tabItem(withID: "b")) + XCTAssertEqual( + header.tabStack.arrangedSubviews.compactMap { ($0 as? BrowserTabItemView)?.id }, + ["c", "a", "d"] + ) + } + + func testTabHoverRemainsActiveAcrossTitleAndCloseButton() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 80), + styleMask: [.titled, .fullSizeContentView], + backing: .buffered, + defer: false + ) + let container = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 80)) + let header = BrowserTabHeaderView(frame: .zero) + container.addSubview(header) + NSLayoutConstraint.activate([ + header.leadingAnchor.constraint(equalTo: container.leadingAnchor), + header.trailingAnchor.constraint(equalTo: container.trailingAnchor), + header.topAnchor.constraint(equalTo: container.topAnchor), + ]) + window.contentView = container + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + let palette = BrowserChromePalette([ + "primary": "#101114", + "surfaceSoft": "#24262c", + "primaryText": "#fefefe", + "secondaryText": "#777980", + ]) + header.applyPalette(palette) + header.reconcileTabs([ + BrowserTabItemState(id: "tab", title: "Readable", isSelected: false, statusColor: .systemGreen), + ], onSelect: { _ in }, onClose: { _ in }) + container.layoutSubtreeIfNeeded() + header.layoutSubtreeIfNeeded() + + let item = try XCTUnwrap(header.tabItem(withID: "tab")) + let edge = item.selectionButton.convert( + NSPoint(x: 2, y: item.selectionButton.bounds.midY), + to: nil + ) + let event = try XCTUnwrap(NSEvent.mouseEvent( + with: .mouseMoved, + location: edge, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 0, + pressure: 0 + )) + item.selectionButton.mouseEntered(with: event) + + XCTAssertTrue(item.isHoverHighlighted) + let titlePoint = item.convert( + NSPoint(x: item.titleFrame.midX, y: item.titleFrame.midY), + to: header + ) + let headerPoint = header.superview.map { header.convert(titlePoint, to: $0) } ?? titlePoint + XCTAssertTrue(header.hitTest(headerPoint) === item.selectionButton) + XCTAssertTrue(item.isHoverHighlighted) + assertColor(item.displayedTextColor, red: 254, green: 254, blue: 254, alpha: 255) + + item.selectionButton.mouseExited(with: event) + XCTAssertFalse(item.isHoverHighlighted) + item.closeButton.mouseEntered(with: event) + XCTAssertTrue(item.isHoverHighlighted) + assertColor(item.displayedTextColor, red: 254, green: 254, blue: 254, alpha: 255) + item.closeButton.mouseExited(with: event) + XCTAssertFalse(item.isHoverHighlighted) + } + + func testTabStateColorsAreOpaqueAndReadableInLightAndDarkPalettes() { + let palettes = [ + BrowserChromePalette([ + "primary": "#0d0d0f", + "secondary": "#1b1b1f", + "primaryText": "#f5f1e8", + "secondaryText": "rgba(222, 214, 200, 0.66)", + "surfaceSoft": "rgba(245, 241, 232, 0.05)", + "accent": "#d6c2a1", + ]), + BrowserChromePalette([ + "primary": "#f2f2f7", + "secondary": "#ffffff", + "primaryText": "#000000", + "secondaryText": "#3c3c43", + "surfaceSoft": "rgba(60, 60, 67, 0.06)", + "accent": "#5856d6", + ]), + ] + + for palette in palettes { + for background in [ + palette.tabHoverBackground, + palette.tabSelectedBackground, + palette.tabPressedBackground, + ] { + XCTAssertEqual( + background.usingColorSpace(.sRGB)?.alphaComponent ?? -1, + 1, + accuracy: 0.001 + ) + XCTAssertGreaterThanOrEqual( + contrastRatio(palette.primaryText, background), + 4.5 + ) + } + } + } + + func testTabControlsOwnClicksInsteadOfMovingWindow() throws { + let header = BrowserTabHeaderView(frame: NSRect(x: 0, y: 0, width: 400, height: 40)) + header.reconcileTabs([ + BrowserTabItemState(id: "tab", title: "Tab", isSelected: true, statusColor: .systemGreen), + ], onSelect: { _ in }, onClose: { _ in }) + header.layoutSubtreeIfNeeded() + let item = try XCTUnwrap(header.tabItem(withID: "tab")) + let point = item.convert(NSPoint(x: item.bounds.midX, y: item.bounds.midY), to: header) + let closePoint = item.closeButton.convert( + NSPoint(x: item.closeButton.bounds.midX, y: item.closeButton.bounds.midY), + to: header + ) + let titlePoint = item.convert( + NSPoint(x: item.titleFrame.midX, y: item.titleFrame.midY), + to: header + ) + let statusPoint = item.convert( + NSPoint(x: item.statusFrame.midX, y: item.statusFrame.midY), + to: header + ) + let edgePoint = item.convert( + NSPoint(x: 2, y: item.bounds.midY), + to: header + ) + + XCTAssertTrue(header.hitTest(point) === item.selectionButton) + XCTAssertTrue(header.hitTest(titlePoint) === item.selectionButton) + XCTAssertTrue(header.hitTest(statusPoint) === item.selectionButton) + XCTAssertTrue(header.hitTest(edgePoint) === item.selectionButton) + XCTAssertTrue(header.hitTest(closePoint) === item.closeButton) + XCTAssertFalse(header.mouseDownCanMoveWindow) + XCTAssertFalse(item.mouseDownCanMoveWindow) + XCTAssertFalse(item.selectionButton.mouseDownCanMoveWindow) + XCTAssertFalse(item.closeButton.mouseDownCanMoveWindow) + } + + func testRealWindowHitTestingRoutesTabsInBothPresentations() throws { + for presentation: BrowserPresentation in [.sidebar, .window] { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 80), + styleMask: [.titled, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isMovableByWindowBackground = false + let container = NSView(frame: NSRect(x: 0, y: 0, width: 500, height: 80)) + let header = BrowserTabHeaderView(frame: .zero) + container.addSubview(header) + NSLayoutConstraint.activate([ + header.leadingAnchor.constraint(equalTo: container.leadingAnchor), + header.trailingAnchor.constraint(equalTo: container.trailingAnchor), + header.topAnchor.constraint(equalTo: container.topAnchor), + ]) + window.contentView = container + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + header.applyPresentation(presentation) + var selections: [String] = [] + var closes: [String] = [] + header.reconcileTabs([ + BrowserTabItemState(id: "first", title: "First", isSelected: true, statusColor: .systemGreen), + BrowserTabItemState(id: "second", title: "Second", isSelected: false, statusColor: .systemGreen), + ], onSelect: { selections.append($0) }, onClose: { closes.append($0) }) + container.layoutSubtreeIfNeeded() + header.layoutSubtreeIfNeeded() + let second = try XCTUnwrap(header.tabItem(withID: "second")) + routeClick(to: second.selectionButton, through: header, in: window) + XCTAssertEqual(selections, ["second"], "presentation: \(presentation)") + routeClick(to: second.closeButton, through: header, in: window) + XCTAssertEqual(closes, ["second"], "presentation: \(presentation)") + } + } + + private func assertColor( + _ color: NSColor, + red: Int, + green: Int, + blue: Int, + alpha: Int, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard let rgb = color.usingColorSpace(.sRGB) else { + XCTFail("Expected an sRGB-compatible color", file: file, line: line) + return + } + XCTAssertEqual(rgb.redComponent, CGFloat(red) / 255, accuracy: 0.002, file: file, line: line) + XCTAssertEqual(rgb.greenComponent, CGFloat(green) / 255, accuracy: 0.002, file: file, line: line) + XCTAssertEqual(rgb.blueComponent, CGFloat(blue) / 255, accuracy: 0.002, file: file, line: line) + XCTAssertEqual(rgb.alphaComponent, CGFloat(alpha) / 255, accuracy: 0.002, file: file, line: line) + } + + private func contrastRatio(_ first: NSColor, _ second: NSColor) -> CGFloat { + let firstLuminance = relativeLuminance(first) + let secondLuminance = relativeLuminance(second) + let lighter = max(firstLuminance, secondLuminance) + let darker = min(firstLuminance, secondLuminance) + return (lighter + 0.05) / (darker + 0.05) + } + + private func relativeLuminance(_ color: NSColor) -> CGFloat { + guard let rgb = color.usingColorSpace(.sRGB) else { return 0 } + func linearize(_ channel: CGFloat) -> CGFloat { + channel <= 0.04045 + ? channel / 12.92 + : pow((channel + 0.055) / 1.055, 2.4) + } + return 0.2126 * linearize(rgb.redComponent) + + 0.7152 * linearize(rgb.greenComponent) + + 0.0722 * linearize(rgb.blueComponent) + } + + private func routeClick(to view: NSView, through header: NSView, in window: NSWindow) { + let headerPoint = view.convert( + NSPoint(x: view.bounds.midX, y: view.bounds.midY), + to: header + ) + let location = header.convert(headerPoint, to: nil) + let hitTestPoint = header.superview.map { header.convert(headerPoint, to: $0) } ?? headerPoint + let hitView = header.hitTest(hitTestPoint) + let contentPoint = window.contentView?.convert(location, from: nil) ?? .zero + let windowHitView = window.contentView?.hitTest(contentPoint) + XCTAssertTrue( + hitView === view, + "hit=\(String(describing: hitView)), header=\(header.frame), item=\(view.frame), point=\(headerPoint)" + ) + XCTAssertTrue( + windowHitView === view, + "windowHit=\(String(describing: windowHitView)), contentPoint=\(contentPoint)" + ) + (windowHitView as? NSButton)?.performClick(nil) + } +} + +final class BrowserSidebarMetricsTests: XCTestCase { + func testWebKitWindowLiveResizePolicyUsesAppKitContentPreservation() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .resizable], + backing: .buffered, + defer: false + ) + let host = NSView(frame: window.contentView?.bounds ?? .zero) + window.preservesContentDuringLiveResize = false + BrowserLiveResizePolicy.apply(to: window, views: [host]) + + XCTAssertTrue(window.preservesContentDuringLiveResize) + XCTAssertEqual(host.layerContentsRedrawPolicy, .duringViewResize) + } + + func testResizeUnderlayCoversContentAndChromeWithoutInterceptingClicks() throws { + let underlay = BrowserResizeUnderlayView( + frame: NSRect(x: 0, y: 0, width: 400, height: 760) + ) + let palette = BrowserChromePalette([ + "primary": "#102030", + "secondary": "#405060", + ]) + underlay.apply(content: palette.primary, chrome: palette.chromeBackground) + underlay.layoutSubtreeIfNeeded() + + XCTAssertEqual(underlay.contentFillFrame, underlay.bounds) + XCTAssertEqual(underlay.chromeFillFrame, NSRect(x: 0, y: 720, width: 400, height: 40)) + XCTAssertEqual(underlay.contentColor, palette.primary) + XCTAssertEqual(underlay.chromeColor, palette.chromeBackground) + XCTAssertNil(underlay.hitTest(NSPoint(x: 10, y: 10))) + } + + func testResizeUnderlayRemainsContiguousAcrossSupportedWidths() { + let underlay = BrowserResizeUnderlayView(frame: .zero) + for width in stride(from: CGFloat(280), through: 720, by: 4) { + underlay.frame = NSRect(x: 0, y: 0, width: width, height: 760) + underlay.layoutSubtreeIfNeeded() + XCTAssertEqual(underlay.contentFillFrame, underlay.bounds) + XCTAssertEqual(underlay.chromeFillFrame.minX, underlay.bounds.minX) + XCTAssertEqual(underlay.chromeFillFrame.maxX, underlay.bounds.maxX) + XCTAssertEqual(underlay.chromeFillFrame.maxY, underlay.bounds.maxY) + } + } + + func testTitlebarDragRegionIsReadyAtInitialAndMinimumWorkbenchWidths() { + for width: CGFloat in [360, 700, 1_100] { + let frame = TitlebarDragRegion.frame( + in: NSRect(x: 0, y: 0, width: width, height: 760), + trafficLightTrailingEdge: 82 + ) + XCTAssertGreaterThan(frame.width, 0) + XCTAssertEqual(frame.height, 40) + XCTAssertEqual(frame.maxY, 760) + XCTAssertLessThanOrEqual(frame.maxX, width) + } + } + + func testAttachesBrowserDirectlyBetweenNonzeroSidebarAndWindowHosts() { + let windowHost = NSView(frame: NSRect(x: 0, y: 0, width: 1_120, height: 760)) + let browserView = BrowserAttachmentWidthRecordingView(frame: windowHost.bounds) + windowHost.addSubview(browserView) + browserView.resetAssignedWidths() + + for width: CGFloat in [280, 400, 720] { + let sidebarHost = NSView(frame: NSRect(x: 0, y: 0, width: width, height: 760)) + + XCTAssertTrue(BrowserViewAttachment.attach(browserView, to: sidebarHost)) + XCTAssertTrue(browserView.superview === sidebarHost) + XCTAssertEqual(browserView.frame, sidebarHost.bounds) + + XCTAssertTrue(BrowserViewAttachment.attach(browserView, to: windowHost)) + XCTAssertTrue(browserView.superview === windowHost) + XCTAssertEqual(browserView.frame, windowHost.bounds) + } + + XCTAssertFalse(browserView.assignedWidths.contains(0)) + } + + func testDefersAttachmentWhenDestinationHasNoWidth() { + let sourceHost = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 760)) + let hiddenHost = NSView(frame: NSRect(x: 0, y: 0, width: 0, height: 760)) + let browserView = NSView(frame: sourceHost.bounds) + sourceHost.addSubview(browserView) + + XCTAssertFalse(BrowserViewAttachment.attach(browserView, to: hiddenHost)) + XCTAssertTrue(browserView.superview === sourceHost) + XCTAssertEqual(browserView.frame.width, 400) + } + + func testCompletesDeferredAttachmentAfterHostReceivesItsFirstWidth() { + let sourceHost = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 760)) + let startupHost = NSView(frame: NSRect(x: 0, y: 0, width: 0, height: 760)) + let browserView = BrowserAttachmentWidthRecordingView(frame: sourceHost.bounds) + sourceHost.addSubview(browserView) + browserView.resetAssignedWidths() + + XCTAssertFalse(BrowserViewAttachment.attach(browserView, to: startupHost)) + XCTAssertTrue(browserView.superview === sourceHost) + + startupHost.frame.size.width = 920 + XCTAssertTrue(BrowserViewAttachment.attach(browserView, to: startupHost)) + XCTAssertTrue(browserView.superview === startupHost) + XCTAssertEqual(browserView.frame, startupHost.bounds) + XCTAssertFalse(browserView.assignedWidths.contains(0)) + } + + func testSynchronizesHostedContentThroughoutContinuousResize() { + let workbenchHost = NSView(frame: NSRect(x: 0, y: 0, width: 920, height: 760)) + let sidebarHost = NSView(frame: NSRect(x: 920, y: 0, width: 280, height: 760)) + let workbenchContent = BrowserAttachmentWidthRecordingView(frame: workbenchHost.bounds) + let sidebarContent = BrowserAttachmentWidthRecordingView(frame: sidebarHost.bounds) + XCTAssertTrue(BrowserViewAttachment.attach(workbenchContent, to: workbenchHost)) + XCTAssertTrue(BrowserViewAttachment.attach(sidebarContent, to: sidebarHost)) + workbenchContent.resetAssignedWidths() + sidebarContent.resetAssignedWidths() + + for sidebarWidth in stride(from: CGFloat(280), through: 840, by: 4) { + workbenchHost.frame = NSRect( + x: 0, + y: 0, + width: 1_200 - sidebarWidth, + height: 760 + ) + sidebarHost.frame = NSRect( + x: 1_200 - sidebarWidth, + y: 0, + width: sidebarWidth, + height: 760 + ) + BrowserViewAttachment.synchronize(workbenchContent, with: workbenchHost) + BrowserViewAttachment.synchronize(sidebarContent, with: sidebarHost) + XCTAssertEqual(workbenchContent.frame, workbenchHost.bounds) + XCTAssertEqual(sidebarContent.frame, sidebarHost.bounds) + } + + XCTAssertFalse(workbenchContent.assignedWidths.contains(0)) + XCTAssertFalse(sidebarContent.assignedWidths.contains(0)) + } + + func testDividerCursorOwnsOnlyItsEightPointBounds() { + let bounds = NSRect(x: 0, y: 0, width: 8, height: 760) + XCTAssertEqual(BrowserDividerCursorRegion.rect(in: bounds), bounds) + } + + func testResizeLifecycleEmitsOneStartAndEndAcrossSources() { + var lifecycle = BrowserResizeLifecycle() + XCTAssertTrue(lifecycle.begin(.sidebar)) + XCTAssertFalse(lifecycle.begin(.sidebar)) + XCTAssertFalse(lifecycle.begin(.window)) + XCTAssertFalse(lifecycle.end(.sidebar)) + XCTAssertFalse(lifecycle.end(.sidebar)) + XCTAssertTrue(lifecycle.end(.window)) + XCTAssertFalse(lifecycle.end(.window)) + XCTAssertTrue(lifecycle.activeSources.isEmpty) + } + + func testHiddenSidebarRetainsItsWidthWithoutOccupyingWorkbenchSpace() { + let bounds = NSRect(x: 0, y: 0, width: 1_120, height: 760) + let hidden = BrowserSidebarMetrics.layout( + in: bounds, + preferredWidth: 400, + isVisible: false + ) + + XCTAssertEqual(hidden.workbenchFrame, bounds) + XCTAssertEqual(hidden.sidebarFrame, NSRect(x: 1_120, y: 0, width: 400, height: 760)) + + let visible = BrowserSidebarMetrics.layout( + in: bounds, + preferredWidth: hidden.sidebarFrame.width, + isVisible: true + ) + XCTAssertEqual(visible.workbenchFrame.width, 720) + XCTAssertEqual(visible.sidebarFrame, NSRect(x: 720, y: 0, width: 400, height: 760)) + } + + func testBrowserToolbarFitsEverySupportedSidebarWidth() { + for width: CGFloat in [280, 400, 720] { + let toolbar = NSStackView(frame: NSRect(x: 0, y: 0, width: width, height: 46)) + toolbar.orientation = .horizontal + toolbar.alignment = .centerY + toolbar.distribution = .fill + toolbar.spacing = 3 + toolbar.edgeInsets = NSEdgeInsets(top: 5, left: 8, bottom: 5, right: 8) + + let leadingButtons = (0..<4).map { _ in NSButton() } + let trailingButtons = (0..<2).map { _ in NSButton() } + let buttons = leadingButtons + trailingButtons + for button in buttons { + button.translatesAutoresizingMaskIntoConstraints = false + button.widthAnchor.constraint(equalToConstant: 30).isActive = true + button.heightAnchor.constraint(equalToConstant: 30).isActive = true + } + for button in leadingButtons { + toolbar.addArrangedSubview(button) + } + + let addressBar = BrowserAddressBar() + toolbar.addArrangedSubview(addressBar) + for button in trailingButtons { + toolbar.addArrangedSubview(button) + } + toolbar.layoutSubtreeIfNeeded() + + XCTAssertFalse(toolbar.hasAmbiguousLayout, "toolbar width: \(width)") + XCTAssertGreaterThan(addressBar.frame.width, 0, "toolbar width: \(width)") + XCTAssertLessThan( + addressBar.frame.maxX, + trailingButtons[0].frame.minX, + "toolbar width: \(width)" + ) + XCTAssertLessThanOrEqual(trailingButtons[1].frame.maxX, width - 8, "toolbar width: \(width)") + XCTAssertGreaterThan(addressBar.textField.frame.width, 0, "toolbar width: \(width)") + for button in buttons { + XCTAssertEqual(button.frame.width, 30, accuracy: 0.001, "toolbar width: \(width)") + XCTAssertFalse(button.hasAmbiguousLayout, "toolbar width: \(width)") + } + } + } + + func testBrowserRootKeepsActiveContentVisibleAtSupportedWidths() { + for width: CGFloat in [280, 400, 720] { + let root = NSView(frame: NSRect(x: 0, y: 0, width: width, height: 760)) + let stack = NSStackView() + let content = NSView() + BrowserRootLayout.configure(stack: stack, content: content) + stack.translatesAutoresizingMaskIntoConstraints = false + root.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: root.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: root.trailingAnchor), + stack.topAnchor.constraint(equalTo: root.topAnchor), + stack.bottomAnchor.constraint(equalTo: root.bottomAnchor), + ]) + + for height: CGFloat in [40, 46] { + let chrome = NSView() + chrome.translatesAutoresizingMaskIntoConstraints = false + chrome.heightAnchor.constraint(equalToConstant: height).isActive = true + stack.addArrangedSubview(chrome) + } + content.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(content) + content.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + let activePage = NSView() + activePage.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(activePage) + NSLayoutConstraint.activate([ + activePage.leadingAnchor.constraint(equalTo: content.leadingAnchor), + activePage.trailingAnchor.constraint(equalTo: content.trailingAnchor), + activePage.topAnchor.constraint(equalTo: content.topAnchor), + activePage.bottomAnchor.constraint(equalTo: content.bottomAnchor), + ]) + + root.layoutSubtreeIfNeeded() + XCTAssertFalse(stack.hasAmbiguousLayout, "width: \(width)") + XCTAssertFalse(content.hasAmbiguousLayout, "width: \(width)") + XCTAssertGreaterThan(content.frame.width, 0, "width: \(width)") + XCTAssertGreaterThan(content.frame.height, 0, "width: \(width)") + XCTAssertEqual(activePage.frame, content.bounds, "width: \(width)") + } + } + + func testClampsWidthAgainstAdaptiveWindowLimits() { + XCTAssertEqual( + BrowserSidebarMetrics.clampedWidth(100, containerWidth: 1_200), + 280 + ) + XCTAssertEqual( + BrowserSidebarMetrics.clampedWidth(900, containerWidth: 1_200), + 840 + ) + XCTAssertEqual( + BrowserSidebarMetrics.clampedWidth(700, containerWidth: 900), + 540 + ) + XCTAssertEqual( + BrowserSidebarMetrics.clampedWidth(400, containerWidth: 600), + 280 + ) + XCTAssertEqual( + BrowserSidebarMetrics.clampedWidth(2_000, containerWidth: 2_400), + 1_680 + ) + } + + func testPresentationTransitionsBetweenSidebarAndWindow() { + var state = BrowserPresentationState() + XCTAssertEqual(state.value, .sidebar) + + state.moveToWindow() + XCTAssertEqual(state.value, .window) + + state.moveToSidebar() + XCTAssertEqual(state.value, .sidebar) + } + + func testPersistsAndLoadsSidebarWidth() { + let suiteName = "BrowserSidebarWidthStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = BrowserSidebarWidthStore(defaults: defaults, key: "width") + + XCTAssertEqual(store.load(), BrowserSidebarMetrics.defaultWidth) + store.save(1_400) + XCTAssertEqual(store.load(), 1_400) + } +} + +final class BrowserTrustStateTests: XCTestCase { + func testTrustEvaluationStartsOnItsRequiredQueue() { + let queue = DispatchQueue(label: "BrowserTrustStateTests.evaluation") + let queueKey = DispatchSpecificKey() + queue.setSpecific(key: queueKey, value: "evaluation") + let scheduled = expectation(description: "trust evaluation scheduled") + + BrowserTrustEvaluationScheduler.schedule(on: queue) { suppliedQueue in + XCTAssertTrue(suppliedQueue === queue) + XCTAssertEqual(DispatchQueue.getSpecific(key: queueKey), "evaluation") + dispatchPrecondition(condition: .onQueue(suppliedQueue)) + scheduled.fulfill() + } + + wait(for: [scheduled], timeout: 1) + } + + func testSecTrustAsyncEvaluationHonorsTheScheduledQueueContract() throws { + var anchorValues: CFArray? + XCTAssertEqual(SecTrustCopyAnchorCertificates(&anchorValues), errSecSuccess) + let certificate = try XCTUnwrap((anchorValues as? [SecCertificate])?.first) + var trustValue: SecTrust? + XCTAssertEqual( + SecTrustCreateWithCertificates( + certificate, + SecPolicyCreateBasicX509(), + &trustValue + ), + errSecSuccess + ) + let trust = try XCTUnwrap(trustValue) + let queue = DispatchQueue(label: "BrowserTrustStateTests.security-evaluation") + let evaluated = expectation(description: "Security trust callback") + + BrowserTrustEvaluationScheduler.schedule(on: queue) { suppliedQueue in + let status = SecTrustEvaluateAsyncWithError(trust, suppliedQueue) { + _, _, _ in + dispatchPrecondition(condition: .onQueue(suppliedQueue)) + evaluated.fulfill() + } + XCTAssertEqual(status, errSecSuccess) + } + + wait(for: [evaluated], timeout: 3) + } + + func testCertificateExceptionRequiresExactHostPortAndFingerprint() { + let store = BrowserTrustExceptionStore() + let approved = BrowserTrustException( + host: "localhost", + port: 8443, + certificateFingerprint: "AA:BB" + ) + store.approve(approved) + + XCTAssertTrue(store.contains(approved)) + XCTAssertFalse(store.contains(.init( + host: "localhost", + port: 9443, + certificateFingerprint: "AA:BB" + ))) + XCTAssertFalse(store.contains(.init( + host: "localhost", + port: 8443, + certificateFingerprint: "CC:DD" + ))) + + store.removeAll() + XCTAssertFalse(store.contains(approved)) + } + + func testWarningTokenCanOnlyBeConsumedOnce() { + let store = BrowserOneTimeTokenStore() + let token = store.issue() + + XCTAssertTrue(store.consume(token)) + XCTAssertFalse(store.consume(token)) + XCTAssertFalse(store.consume("unknown")) + } +} diff --git a/platforms/macos/Tests/WindowChromeSupportTests/ContextMenuSupportTests.swift b/platforms/macos/Tests/WindowChromeSupportTests/ContextMenuSupportTests.swift new file mode 100644 index 0000000..7cef811 --- /dev/null +++ b/platforms/macos/Tests/WindowChromeSupportTests/ContextMenuSupportTests.swift @@ -0,0 +1,109 @@ +@testable import WindowChromeSupport +import XCTest + +final class ContextMenuCoordinateConverterTests: XCTestCase { + private let viewport = ContextMenuViewportMetrics( + layoutWidth: 1200, + layoutHeight: 800, + visualWidth: 600, + visualHeight: 400, + visualOffsetLeft: 100, + visualOffsetTop: 50, + visualScale: 2, + deviceScaleFactor: 4 + ) + + func testConvertsIntoFlippedViewCoordinatesWithVisualViewportOffset() { + let point = ContextMenuCoordinateConverter.convert( + CGPoint(x: 250, y: 150), + viewport: viewport, + viewBounds: CGRect(x: 20, y: 30, width: 900, height: 600), + isFlipped: true + ) + XCTAssertEqual(point.x, 245, accuracy: 0.001) + XCTAssertEqual(point.y, 180, accuracy: 0.001) + } + + func testConvertsIntoNonFlippedViewCoordinates() { + let point = ContextMenuCoordinateConverter.convert( + CGPoint(x: 250, y: 150), + viewport: viewport, + viewBounds: CGRect(x: 20, y: 30, width: 900, height: 600), + isFlipped: false + ) + XCTAssertEqual(point.x, 245, accuracy: 0.001) + XCTAssertEqual(point.y, 480, accuracy: 0.001) + } + + func testRetinaScaleDoesNotChangeLogicalAppKitPoint() { + var oneX = viewport + oneX.deviceScaleFactor = 1 + let bounds = CGRect(x: 0, y: 0, width: 600, height: 400) + XCTAssertEqual( + ContextMenuCoordinateConverter.convert( + CGPoint(x: 300, y: 200), viewport: oneX, viewBounds: bounds, isFlipped: true + ), + ContextMenuCoordinateConverter.convert( + CGPoint(x: 300, y: 200), viewport: viewport, viewBounds: bounds, isFlipped: true + ) + ) + } + + func testUsesBrowserZoomWhenVisualDimensionsAreUnavailable() { + var zoomed = viewport + zoomed.visualWidth = 0 + zoomed.visualHeight = 0 + zoomed.visualOffsetLeft = 0 + zoomed.visualOffsetTop = 0 + let point = ContextMenuCoordinateConverter.convert( + CGPoint(x: 300, y: 200), + viewport: zoomed, + viewBounds: CGRect(x: 0, y: 0, width: 600, height: 400), + isFlipped: true + ) + XCTAssertEqual(point, CGPoint(x: 300, y: 200)) + } + + func testClampsEveryWindowEdge() { + let bounds = CGRect(x: 10, y: 20, width: 300, height: 200) + let upperLeft = ContextMenuCoordinateConverter.convert( + CGPoint(x: -10_000, y: -10_000), viewport: viewport, viewBounds: bounds, isFlipped: true + ) + let lowerRight = ContextMenuCoordinateConverter.convert( + CGPoint(x: 10_000, y: 10_000), viewport: viewport, viewBounds: bounds, isFlipped: true + ) + XCTAssertEqual(upperLeft, CGPoint(x: 11, y: 21)) + XCTAssertEqual(lowerRight, CGPoint(x: 309, y: 219)) + } +} + +final class ContextMenuRequestCoordinatorTests: XCTestCase { + func testNormalSelectionCompletesExactlyOnce() { + var coordinator = ContextMenuRequestCoordinator() + XCTAssertEqual(coordinator.submit(1), [.present(1)]) + XCTAssertEqual(coordinator.trackingEnded(1, selection: "edit.copy"), [ + .complete(1, "edit.copy") + ]) + XCTAssertEqual(coordinator.trackingEnded(1, selection: "stale"), []) + } + + func testRapidRequestsKeepOnlyNewestPendingMenu() { + var coordinator = ContextMenuRequestCoordinator() + _ = coordinator.submit(1) + XCTAssertEqual(coordinator.submit(2), [ + .complete(1, nil), .cancelTracking(1) + ]) + XCTAssertEqual(coordinator.submit(3), [.complete(2, nil)]) + XCTAssertEqual(coordinator.trackingEnded(1, selection: nil), [.present(3)]) + XCTAssertEqual(coordinator.trackingEnded(3, selection: nil), [.complete(3, nil)]) + } + + func testExplicitCancellationCompletesActiveAndPendingOnce() { + var coordinator = ContextMenuRequestCoordinator() + _ = coordinator.submit(10) + _ = coordinator.submit(11) + XCTAssertEqual(coordinator.cancelAll(), [.complete(11, nil)]) + XCTAssertEqual(coordinator.cancelAll(), []) + XCTAssertEqual(coordinator.trackingEnded(10, selection: "stale"), []) + } +} diff --git a/platforms/macos/Tests/WindowChromeSupportTests/DesktopShortcutSupportTests.swift b/platforms/macos/Tests/WindowChromeSupportTests/DesktopShortcutSupportTests.swift new file mode 100644 index 0000000..507b495 --- /dev/null +++ b/platforms/macos/Tests/WindowChromeSupportTests/DesktopShortcutSupportTests.swift @@ -0,0 +1,92 @@ +@testable import WindowChromeSupport +import AppKit +import XCTest + +final class DesktopShortcutSupportTests: XCTestCase { + func testUsesVSCodeTerminalAndNavigationBindings() { + XCTAssertEqual(DesktopShortcutCatalog.toggleTerminal.key, "`") + XCTAssertEqual( + DesktopShortcutCatalog.toggleTerminal.modifiers, + [.control] + ) + XCTAssertEqual(DesktopShortcutCatalog.newTerminal.key, "`") + XCTAssertEqual( + DesktopShortcutCatalog.newTerminal.modifiers, + [.control, .shift] + ) + XCTAssertEqual( + DesktopShortcutCatalog.showExplorer.modifiers, + [.command, .shift] + ) + XCTAssertEqual( + DesktopShortcutCatalog.showSourceControl.modifiers, + [.control, .shift] + ) + XCTAssertEqual(DesktopShortcutCatalog.openFile.key, "o") + XCTAssertEqual(DesktopShortcutCatalog.save.key, "s") + XCTAssertEqual(DesktopShortcutCatalog.closeTab.key, "w") + XCTAssertEqual(DesktopShortcutCatalog.toggleSidebar.key, "b") + XCTAssertEqual(DesktopShortcutCatalog.settings.key, ",") + } + + func testParsesResolvedSingleStrokePayload() { + let resolved = DesktopShortcutCatalog.resolved(payload: [ + "settings": [ + "key": "F6", + "modifiers": ["ctrl", "shift"], + ], + "open-folder": NSNull(), + ]) + XCTAssertEqual(resolved["settings"]?.key, "f6") + XCTAssertEqual( + resolved["settings"]?.modifiers, + [.control, .shift] + ) + XCTAssertNil(resolved["open-folder"]) + XCTAssertNil(resolved["toggle-sidebar"]) + } + + func testContextualNewUsesTheActiveCommandAndPreservesDirectBindings() { + let shortcuts: [String: DesktopNativeShortcut] = [ + "contextual-new": DesktopNativeShortcut( + command: "contextual-new", + key: "n", + modifiers: [.command] + ), + "new-file": DesktopNativeShortcut( + command: "new-file", + key: "f", + modifiers: [.control] + ), + "new-chat": DesktopNativeShortcut( + command: "new-chat", + key: "c", + modifiers: [.control] + ), + ] + XCTAssertEqual( + DesktopShortcutCatalog.contextualShortcut( + for: "new-file", + contextualCommand: nil, + shortcuts: shortcuts + )?.command, + "contextual-new" + ) + XCTAssertEqual( + DesktopShortcutCatalog.contextualShortcut( + for: "new-chat", + contextualCommand: nil, + shortcuts: shortcuts + )?.key, + "c" + ) + XCTAssertEqual( + DesktopShortcutCatalog.contextualShortcut( + for: "new-chat", + contextualCommand: "new-chat", + shortcuts: shortcuts + )?.command, + "contextual-new" + ) + } +} diff --git a/platforms/macos/Tests/WindowChromeSupportTests/LocalCLIActivationClientTests.swift b/platforms/macos/Tests/WindowChromeSupportTests/LocalCLIActivationClientTests.swift new file mode 100644 index 0000000..ea2fa03 --- /dev/null +++ b/platforms/macos/Tests/WindowChromeSupportTests/LocalCLIActivationClientTests.swift @@ -0,0 +1,434 @@ +@testable import WindowChromeSupport +import Darwin +import Foundation +import XCTest + +final class LocalCLIActivationLockLoaderTests: XCTestCase { + private let home = URL(fileURLWithPath: "/test/home", isDirectory: true) + private let temporary = URL(fileURLWithPath: "/test/tmp", isDirectory: true) + + func testLoadsValidBootLockFromCLIPaths() throws { + let loader = makeLoader { url in + if url.lastPathComponent == "config.json" { + return Data(#"{"machineId":"machine-123"}"#.utf8) + } + XCTAssertEqual(url.lastPathComponent, "shellular-machine-123-friend.lock") + return self.lockData() + } + + XCTAssertEqual(try loader.load(), makeLock()) + } + + func testReturnsNilWhenConfigurationOrLockDoesNotExist() throws { + let missingConfig = makeLoader { _ in throw CocoaError(.fileNoSuchFile) } + XCTAssertNil(try missingConfig.load()) + + let missingLock = makeLoader { url in + if url.lastPathComponent == "config.json" { + return Data(#"{"machineId":"machine-123"}"#.utf8) + } + throw CocoaError(.fileNoSuchFile) + } + XCTAssertNil(try missingLock.load()) + } + + func testRejectsMalformedConfigurationAndPersistentMalformedLock() { + let malformedConfig = makeLoader { _ in Data("{".utf8) } + XCTAssertThrowsError(try malformedConfig.load()) { + XCTAssertEqual(($0 as? LocalCLIActivationError)?.code, "INVALID_CLI_STATE") + } + + var lockReads = 0 + let malformedLock = makeLoader { url in + if url.lastPathComponent == "config.json" { + return Data(#"{"machineId":"machine-123"}"#.utf8) + } + lockReads += 1 + return Data("{".utf8) + } + XCTAssertThrowsError(try malformedLock.load()) { + XCTAssertEqual(($0 as? LocalCLIActivationError)?.code, "INVALID_CLI_STATE") + } + XCTAssertEqual(lockReads, 3) + } + + func testRetriesPartiallyWrittenLock() throws { + var lockReads = 0 + var pauses = 0 + let loader = makeLoader( + readData: { url in + if url.lastPathComponent == "config.json" { + return Data(#"{"machineId":"machine-123"}"#.utf8) + } + lockReads += 1 + return lockReads == 1 ? Data("{".utf8) : self.lockData() + }, + pause: { pauses += 1 } + ) + + XCTAssertEqual(try loader.load(), makeLock()) + XCTAssertEqual(lockReads, 2) + XCTAssertEqual(pauses, 1) + } + + private func makeLoader( + readData: @escaping (URL) throws -> Data, + pause: @escaping () -> Void = {} + ) -> LocalCLIActivationLockLoader { + LocalCLIActivationLockLoader( + homeDirectory: home, + temporaryDirectory: temporary, + username: "friend", + readData: readData, + pauseBetweenReads: pause + ) + } + + private func lockData() -> Data { + Data( + """ + { + "pid": 123, + "instanceId": "instance", + "cliVersion": "0.0.49", + "activationProtocolVersion": 1, + "activationEndpoint": "/test/tmp/shellular.sock", + "activationSecret": "ssssssssssssssssssssssssssssssss", + "activationReady": true + } + """.utf8 + ) + } +} + +final class LocalCLIActivationProtocolTests: XCTestCase { + func testSerializesExactVersionOneActivationRequest() throws { + let data = try LocalCLIActivationClient.makeRequestData( + requestId: "request", + instanceId: "instance", + secret: String(repeating: "s", count: 32), + token: String(repeating: "t", count: 32), + ownerId: "owner", + port: 51238 + ) + XCTAssertEqual(data.last, 0x0A) + + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: data.dropLast()) as? [String: Any] + ) + XCTAssertEqual(object["type"] as? String, "ACTIVATE_LOCAL") + XCTAssertEqual(object["protocolVersion"] as? Int, 1) + XCTAssertEqual(object["requestId"] as? String, "request") + XCTAssertEqual(object["targetInstanceId"] as? String, "instance") + XCTAssertEqual(object["secret"] as? String, String(repeating: "s", count: 32)) + XCTAssertEqual(object["requiredLocalProtocolVersion"] as? Int, 1) + XCTAssertEqual(object["port"] as? Int, 51238) + XCTAssertEqual(object["token"] as? String, String(repeating: "t", count: 32)) + XCTAssertEqual(object["source"] as? String, "attached") + XCTAssertEqual(object["ownerId"] as? String, "owner") + } + + func testParsesSuccessResponse() throws { + let result = try LocalCLIActivationClient.parseResponse( + successResponse(requestId: "request"), + expectedRequestId: "request" + ) + XCTAssertEqual(result, LocalCLIActivationResult( + state: "started", + pid: 123, + port: 51238, + cliVersion: "0.0.49", + localProtocolVersion: 1 + )) + } + + func testRejectsMismatchedMalformedAndUnsupportedResponses() { + XCTAssertThrowsError(try LocalCLIActivationClient.parseResponse( + successResponse(requestId: "other"), + expectedRequestId: "request" + )) { + XCTAssertEqual(($0 as? LocalCLIActivationError)?.code, "INVALID_RESPONSE") + } + XCTAssertThrowsError(try LocalCLIActivationClient.parseResponse( + Data(#"{"requestId":"request""#.utf8), + expectedRequestId: "request" + )) { + XCTAssertEqual(($0 as? LocalCLIActivationError)?.code, "INVALID_RESPONSE") + } + XCTAssertThrowsError(try LocalCLIActivationClient.parseResponse( + successResponse(requestId: "request", localProtocolVersion: 2), + expectedRequestId: "request" + )) { + let error = $0 as? LocalCLIActivationError + XCTAssertEqual(error?.code, "EXISTING_CLI_UNSUPPORTED") + XCTAssertEqual(error?.currentVersion, "0.0.49") + } + } + + func testSurfacesStructuredRemoteError() { + let response = jsonLine( + #"{"requestId":"request","ok":false,"error":{"code":"PORT_IN_USE","message":"Port is busy","currentVersion":"0.0.49"}}"# + ) + XCTAssertThrowsError(try LocalCLIActivationClient.parseResponse( + response, + expectedRequestId: "request" + )) { + XCTAssertEqual($0 as? LocalCLIActivationError, LocalCLIActivationError( + code: "PORT_IN_USE", + message: "Port is busy", + currentVersion: "0.0.49" + )) + } + } +} + +final class LocalCLIActivationClientTests: XCTestCase { + func testReturnsNilForStaleCLIWithoutCallingTransport() throws { + var exchanged = false + let client = makeClient( + processChecker: { _ in false }, + exchange: { _, _, _ in exchanged = true; return Data() } + ) + + XCTAssertNil(try client.activate(token: token, ownerId: "owner", port: 0)) + XCTAssertFalse(exchanged) + } + + func testRejectsLiveIncompatibleCLIWithoutStartingAnother() { + let client = makeClient(lock: makeLock(activationProtocolVersion: nil)) + + XCTAssertThrowsError(try client.activate(token: token, ownerId: "owner", port: 0)) { + let error = $0 as? LocalCLIActivationError + XCTAssertEqual(error?.code, "EXISTING_CLI_UNSUPPORTED") + XCTAssertEqual(error?.currentVersion, "0.0.49") + } + } + + func testRetriesUntilActivationSocketIsReady() throws { + let clock = TestClock() + var loads = 0 + var exchanges = 0 + let client = makeClient( + lockProvider: { + loads += 1 + return makeLock(activationReady: loads > 1) + }, + exchange: { _, request, _ in + exchanges += 1 + return try responseForRequest(request) + }, + clock: clock + ) + + let result = try client.activate(token: token, ownerId: "owner", port: 0) + XCTAssertEqual(result?.port, 51238) + XCTAssertEqual(exchanges, 1) + XCTAssertGreaterThanOrEqual(loads, 2) + } + + func testTimesOutAfterRetryableTransportFailures() { + let clock = TestClock() + var attempts = 0 + let client = makeClient( + exchange: { _, _, _ in + attempts += 1 + throw LocalCLIUnixSocketTransportError.unavailable(ECONNREFUSED) + }, + clock: clock, + activationTimeout: 0.25 + ) + + XCTAssertThrowsError(try client.activate(token: token, ownerId: "owner", port: 0)) { + XCTAssertEqual(($0 as? LocalCLIActivationError)?.code, "ACTIVATION_TIMEOUT") + } + XCTAssertGreaterThan(attempts, 1) + } + + func testUnixSocketIntegrationAuthenticatesAndReturnsPort() throws { + let fixture = try UnixSocketFixture() + let served = expectation(description: "activation served") + fixture.serve { request in + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: request.dropLast()) as? [String: Any] + ) + XCTAssertEqual(object["targetInstanceId"] as? String, "instance") + XCTAssertEqual(object["secret"] as? String, String(repeating: "s", count: 32)) + XCTAssertEqual(object["token"] as? String, self.token) + XCTAssertEqual(object["source"] as? String, "attached") + XCTAssertEqual(object["ownerId"] as? String, "owner") + XCTAssertEqual(object["port"] as? Int, 0) + served.fulfill() + return successResponse(requestId: try XCTUnwrap(object["requestId"] as? String)) + } + + let client = makeClient( + lock: makeLock(activationEndpoint: fixture.path), + exchange: LocalCLIUnixSocketTransport().exchange + ) + let result = try client.activate(token: token, ownerId: "owner", port: 0) + + XCTAssertEqual(result?.pid, 123) + XCTAssertEqual(result?.port, 51238) + wait(for: [served], timeout: 2) + } + + private let token = String(repeating: "t", count: 32) + + private func makeClient( + lock: LocalCLIActivationLock = makeLock(), + lockProvider: (() throws -> LocalCLIActivationLock?)? = nil, + processChecker: @escaping (Int) -> Bool = { _ in true }, + exchange: @escaping LocalCLIActivationClient.Exchange = { _, request, _ in + try responseForRequest(request) + }, + clock: TestClock = TestClock(), + activationTimeout: TimeInterval = 1 + ) -> LocalCLIActivationClient { + LocalCLIActivationClient( + lockProvider: lockProvider ?? { lock }, + processChecker: processChecker, + exchange: exchange, + endpointValidator: { _ in true }, + now: { clock.now }, + sleep: clock.sleep, + requestId: { "request" }, + activationTimeout: activationTimeout, + attemptTimeout: 0.1, + retryInterval: 0.1 + ) + } +} + +private final class TestClock { + var now = Date(timeIntervalSince1970: 0) + func sleep(_ interval: TimeInterval) { now.addTimeInterval(interval) } +} + +private final class UnixSocketFixture { + let path: String + private let descriptor: Int32 + + init() throws { + path = FileManager.default.temporaryDirectory + .appendingPathComponent("shellular-\(UUID().uuidString.prefix(8)).sock").path + descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw POSIXError(.EIO) } + Darwin.unlink(path) + + var address = try makeUnixAddress(path) + let result = withUnsafePointer(to: &address.value) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, address.length) + } + } + guard result == 0, Darwin.listen(descriptor, 1) == 0 else { + let code = errno + Darwin.close(descriptor) + throw POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO) + } + } + + deinit { + Darwin.close(descriptor) + Darwin.unlink(path) + } + + func serve(_ handler: @escaping (Data) throws -> Data) { + DispatchQueue.global(qos: .userInitiated).async { + let client = Darwin.accept(self.descriptor, nil, nil) + guard client >= 0 else { return } + defer { Darwin.close(client) } + + do { + var request = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while request.firstIndex(of: 0x0A) == nil { + let count = buffer.withUnsafeMutableBytes { + Darwin.recv(client, $0.baseAddress, $0.count, 0) + } + guard count > 0 else { return } + request.append(buffer, count: count) + } + let response = try handler(request) + try response.withUnsafeBytes { bytes in + guard let baseAddress = bytes.baseAddress else { return } + var sent = 0 + while sent < bytes.count { + let count = Darwin.send( + client, + baseAddress.advanced(by: sent), + bytes.count - sent, + 0 + ) + guard count > 0 else { throw POSIXError(.EIO) } + sent += count + } + } + } catch { + XCTFail("Unix socket fixture failed: \(error)") + } + } + } +} + +private func makeLock( + activationProtocolVersion: Int? = 1, + activationEndpoint: String = "/test/tmp/shellular.sock", + activationReady: Bool? = true +) -> LocalCLIActivationLock { + LocalCLIActivationLock( + pid: 123, + instanceId: "instance", + cliVersion: "0.0.49", + activationProtocolVersion: activationProtocolVersion, + activationEndpoint: activationEndpoint, + activationSecret: String(repeating: "s", count: 32), + activationReady: activationReady + ) +} + +private func successResponse( + requestId: String, + localProtocolVersion: Int = 1 +) -> Data { + jsonLine( + #"{"requestId":"\#(requestId)","ok":true,"state":"started","pid":123,"port":51238,"cliVersion":"0.0.49","localProtocolVersion":\#(localProtocolVersion)}"# + ) +} + +private func jsonLine(_ value: String) -> Data { + var data = Data(value.utf8) + data.append(0x0A) + return data +} + +private func responseForRequest(_ request: Data) throws -> Data { + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: request.dropLast()) as? [String: Any] + ) + return successResponse(requestId: try XCTUnwrap(object["requestId"] as? String)) +} + +private func makeUnixAddress(_ path: String) throws -> (value: sockaddr_un, length: socklen_t) { + var address = sockaddr_un() + let bytes = Array(path.utf8CString) + let pathOffset = MemoryLayout.offset(of: \.sun_path) ?? 0 + let length = pathOffset + bytes.count + guard !path.isEmpty, + bytes.count <= MemoryLayout.size(ofValue: address.sun_path), + length <= Int(UInt8.max) + else { + throw POSIXError(.ENAMETOOLONG) + } + + address.sun_family = sa_family_t(AF_UNIX) + address.sun_len = UInt8(length) + withUnsafeMutablePointer(to: &address.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: bytes.count) { destination in + for index in bytes.indices { + destination[index] = bytes[index] + } + } + } + return (address, socklen_t(length)) +} diff --git a/platforms/macos/Tests/WindowChromeSupportTests/NativeThemeColorTests.swift b/platforms/macos/Tests/WindowChromeSupportTests/NativeThemeColorTests.swift new file mode 100644 index 0000000..8feee09 --- /dev/null +++ b/platforms/macos/Tests/WindowChromeSupportTests/NativeThemeColorTests.swift @@ -0,0 +1,68 @@ +@testable import WindowChromeSupport +import XCTest + +final class NativeThemeColorParserTests: XCTestCase { + func testParsesHexColorsWithOptionalAlpha() { + XCTAssertEqual( + NativeThemeColorParser.parse("#0d0d0f"), + NativeThemeColor(red: 13.0 / 255, green: 13.0 / 255, blue: 15.0 / 255, alpha: 1) + ) + XCTAssertEqual(NativeThemeColorParser.parse("#fff8"), NativeThemeColor( + red: 1, + green: 1, + blue: 1, + alpha: 136.0 / 255 + )) + } + + func testParsesRGBAndRGBAColors() { + XCTAssertEqual( + NativeThemeColorParser.parse("rgb(242, 242, 247)"), + NativeThemeColor(red: 242.0 / 255, green: 242.0 / 255, blue: 247.0 / 255, alpha: 1) + ) + XCTAssertEqual( + NativeThemeColorParser.parse("rgba(0, 0, 0, 0.6)"), + NativeThemeColor(red: 0, green: 0, blue: 0, alpha: 0.6) + ) + } + + func testRejectsInvalidColors() { + XCTAssertNil(NativeThemeColorParser.parse("red")) + XCTAssertNil(NativeThemeColorParser.parse("rgb(300, 0, 0)")) + XCTAssertNil(NativeThemeColorParser.parse("#12")) + } +} + +final class TitlebarDoubleClickActionTests: XCTestCase { + func testResolvesCurrentAndLegacyFillValues() { + XCTAssertEqual(TitlebarDoubleClickAction.resolve("Fill"), .fill) + XCTAssertEqual(TitlebarDoubleClickAction.resolve("Maximize"), .fill) + XCTAssertEqual(TitlebarDoubleClickAction.resolve("Zoom"), .fill) + XCTAssertEqual(TitlebarDoubleClickAction.resolve(nil), .fill) + } + + func testResolvesMinimizeAndDoNothing() { + XCTAssertEqual(TitlebarDoubleClickAction.resolve("Minimize"), .minimize) + XCTAssertEqual(TitlebarDoubleClickAction.resolve("None"), .none) + XCTAssertEqual(TitlebarDoubleClickAction.resolve("Do Nothing"), .none) + XCTAssertEqual(TitlebarDoubleClickAction.resolve("unsupported"), .none) + } +} + +final class WindowFrameAnimationTimingTests: XCTestCase { + func testClampsAnimationProgress() { + XCTAssertEqual(WindowFrameAnimationTiming.easedProgress(elapsed: -1, duration: 0.18), 0) + XCTAssertEqual(WindowFrameAnimationTiming.easedProgress(elapsed: 1, duration: 0.18), 1) + XCTAssertEqual(WindowFrameAnimationTiming.easedProgress(elapsed: 0.1, duration: 0), 1) + } + + func testEaseInOutProgressIsContinuousAndSymmetric() { + let quarter = WindowFrameAnimationTiming.easedProgress(elapsed: 0.25, duration: 1) + let midpoint = WindowFrameAnimationTiming.easedProgress(elapsed: 0.5, duration: 1) + let threeQuarters = WindowFrameAnimationTiming.easedProgress(elapsed: 0.75, duration: 1) + + XCTAssertEqual(quarter, 0.0625, accuracy: 0.000_001) + XCTAssertEqual(midpoint, 0.5, accuracy: 0.000_001) + XCTAssertEqual(threeQuarters, 0.9375, accuracy: 0.000_001) + } +} diff --git a/platforms/macos/Tests/WindowChromeSupportTests/PrivateFileStoreTests.swift b/platforms/macos/Tests/WindowChromeSupportTests/PrivateFileStoreTests.swift new file mode 100644 index 0000000..3623aae --- /dev/null +++ b/platforms/macos/Tests/WindowChromeSupportTests/PrivateFileStoreTests.swift @@ -0,0 +1,202 @@ +@testable import WindowChromeSupport +import Foundation +import XCTest + +final class PrivateFileStoreTests: XCTestCase { + private var temporaryDirectory: URL! + private var rootDirectory: URL! + + override func setUpWithError() throws { + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "shellular-private-store-tests-\(UUID().uuidString)", + isDirectory: true + ) + rootDirectory = temporaryDirectory.appendingPathComponent( + "PrivateStore", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + if let temporaryDirectory { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + } + + func testMissingValueReturnsNilWithoutCreatingStorage() throws { + let store = makeStore() + + XCTAssertNil(try store.get("missing")) + XCTAssertFalse(FileManager.default.fileExists(atPath: rootDirectory.path)) + XCTAssertNoThrow(try store.remove("missing")) + } + + func testRoundTripsOverwritesAndRemovesValues() throws { + let store = makeStore() + + try store.set("first", forKey: "auth-refresh-token") + XCTAssertEqual(try store.get("auth-refresh-token"), "first") + + try store.set("second", forKey: "auth-refresh-token") + XCTAssertEqual(try store.get("auth-refresh-token"), "second") + + try store.remove("auth-refresh-token") + XCTAssertNil(try store.get("auth-refresh-token")) + XCTAssertNoThrow(try store.remove("auth-refresh-token")) + } + + func testNamespacesAndKeysAreIsolated() throws { + let authStore = makeStore(namespace: "secure-store") + let cliStore = makeStore(namespace: "local-cli") + + try authStore.set("auth", forKey: "shared") + try authStore.set("other", forKey: "other") + try cliStore.set("cli", forKey: "shared") + + XCTAssertEqual(try authStore.get("shared"), "auth") + XCTAssertEqual(try authStore.get("other"), "other") + XCTAssertEqual(try cliStore.get("shared"), "cli") + } + + func testHashesUnicodeAndPathLikeKeysIntoSafeFilenames() throws { + let store = makeStore() + let key = "../../Library/Keychains/🔐" + + try store.set("value", forKey: key) + + XCTAssertEqual(try store.get(key), "value") + let file = try XCTUnwrap(valueFiles(in: "secure-store").first) + XCTAssertEqual(file.pathExtension, "value") + XCTAssertFalse(file.lastPathComponent.contains("..")) + XCTAssertFalse(file.lastPathComponent.contains("Keychains")) + XCTAssertEqual(file.deletingPathExtension().lastPathComponent.count, 64) + } + + func testUsesPrivateDirectoryAndFilePermissions() throws { + let store = makeStore() + try store.set("value", forKey: "key") + + XCTAssertEqual(try permissions(at: rootDirectory), 0o700) + XCTAssertEqual( + try permissions( + at: rootDirectory.appendingPathComponent( + "secure-store", + isDirectory: true + ) + ), + 0o700 + ) + XCTAssertEqual( + try permissions( + at: XCTUnwrap(valueFiles(in: "secure-store").first) + ), + 0o600 + ) + } + + func testAtomicOverwriteLeavesNoTemporaryFiles() throws { + let store = makeStore() + + for index in 0..<20 { + try store.set("value-\(index)", forKey: "key") + } + + XCTAssertEqual(try store.get("key"), "value-19") + let contents = try FileManager.default.contentsOfDirectory( + at: rootDirectory.appendingPathComponent( + "secure-store", + isDirectory: true + ), + includingPropertiesForKeys: nil + ) + XCTAssertEqual(contents.filter { $0.pathExtension == "value" }.count, 1) + XCTAssertFalse(contents.contains { $0.pathExtension == "tmp" }) + } + + func testConcurrentOperationsRemainConsistent() throws { + let store = makeStore() + let errors = ErrorCollector() + + DispatchQueue.concurrentPerform(iterations: 40) { index in + do { + try store.set("value-\(index)", forKey: "key-\(index)") + XCTAssertEqual( + try store.get("key-\(index)"), + "value-\(index)" + ) + } catch { + errors.append(error) + } + } + + XCTAssertTrue(errors.values.isEmpty, "\(errors.values)") + for index in 0..<40 { + XCTAssertEqual( + try store.get("key-\(index)"), + "value-\(index)" + ) + } + } + + func testMalformedValueFailsClosed() throws { + let store = makeStore() + try store.set("valid", forKey: "key") + let file = try XCTUnwrap(valueFiles(in: "secure-store").first) + try Data([0xFF, 0xFE]).write(to: file) + + XCTAssertThrowsError(try store.get("key")) { error in + XCTAssertTrue(error is PrivateFileStoreError) + } + } + + private func makeStore( + namespace: String = "secure-store" + ) -> PrivateFileStore { + PrivateFileStore( + namespace: namespace, + rootDirectory: rootDirectory + ) + } + + private func valueFiles(in namespace: String) throws -> [URL] { + try FileManager.default.contentsOfDirectory( + at: rootDirectory.appendingPathComponent( + namespace, + isDirectory: true + ), + includingPropertiesForKeys: nil + ) + .filter { $0.pathExtension == "value" } + } + + private func permissions(at url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem( + atPath: url.path + ) + return try XCTUnwrap( + attributes[.posixPermissions] as? NSNumber + ).intValue + } +} + +private final class ErrorCollector: @unchecked Sendable { + private let lock = NSLock() + private var storedValues: [Error] = [] + + var values: [Error] { + lock.lock() + defer { lock.unlock() } + return storedValues + } + + func append(_ error: Error) { + lock.lock() + storedValues.append(error) + lock.unlock() + } +} diff --git a/platforms/macos/shellular.xcodeproj/project.pbxproj b/platforms/macos/shellular.xcodeproj/project.pbxproj index 888c260..c4c47ab 100644 --- a/platforms/macos/shellular.xcodeproj/project.pbxproj +++ b/platforms/macos/shellular.xcodeproj/project.pbxproj @@ -17,9 +17,17 @@ 670246D4AFF02259065FFDCB /* bundle in Resources */ = {isa = PBXBuildFile; fileRef = 67C797A43F6F06EB8F3B1E75 /* bundle */; }; 8F354EBEFAB4E4D2E5BFC0DB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 93E2D0EAC882DBBEB887234B /* Assets.xcassets */; }; 8F54DD3E90571D5CA29A106F /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF74D452998AB632E94C07 /* WebView.swift */; }; + D39104FC72E04777A6E18331 /* ShellularWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9576C42983458B9BA108E1 /* ShellularWindow.swift */; }; + F28B7996E66040FD98C7260A /* NativeThemeColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C4128092F348E8B821EBB2 /* NativeThemeColor.swift */; }; + C0A1B2C3D4E5F60718293041 /* ContextMenuSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A1B2C3D4E5F60718293042 /* ContextMenuSupport.swift */; }; ECAC48CB6FFEC2C17B0DFEF3 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF52D9684360646294953B1F /* App.swift */; }; EDEC0196FD87CAA3BC2C9622 /* SocketService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FBC43C303E1D1048DBEFEA /* SocketService.swift */; }; F3F28883E3832A8790FA187B /* ScannerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5851AA5FE9CF461DF6F42203 /* ScannerService.swift */; }; + A11C0CA10000000000000001 /* LocalCLIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C0CA10000000000000002 /* LocalCLIService.swift */; }; + A11C0CA20000000000000001 /* LocalCLIActivationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C0CA20000000000000002 /* LocalCLIActivationClient.swift */; }; + BEEF51010000000000000001 /* PrivateFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF51010000000000000002 /* PrivateFileStore.swift */; }; + D35C0DE10000000000000001 /* DesktopShortcutSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35C0DE10000000000000002 /* DesktopShortcutSupport.swift */; }; + B0A5E0010000000000000001 /* BrowserSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A5E0010000000000000002 /* BrowserSupport.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -32,11 +40,19 @@ 67C797A43F6F06EB8F3B1E75 /* bundle */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = bundle; sourceTree = ""; }; 691AB78EB353B35D93A6A47B /* Services.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = ""; }; 80EF74D452998AB632E94C07 /* WebView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; + 0F9576C42983458B9BA108E1 /* ShellularWindow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShellularWindow.swift; sourceTree = ""; }; + A1C4128092F348E8B821EBB2 /* NativeThemeColor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeThemeColor.swift; sourceTree = ""; }; + C0A1B2C3D4E5F60718293042 /* ContextMenuSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContextMenuSupport.swift; sourceTree = ""; }; 93E2D0EAC882DBBEB887234B /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C4FBC43C303E1D1048DBEFEA /* SocketService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SocketService.swift; sourceTree = ""; }; E12AEBDA3A520F39A1AC0748 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; EF52D9684360646294953B1F /* App.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; FDE1137739EE1ED6755359FA /* EmbeddedProxyService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EmbeddedProxyService.swift; sourceTree = ""; }; + A11C0CA10000000000000002 /* LocalCLIService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalCLIService.swift; sourceTree = ""; }; + A11C0CA20000000000000002 /* LocalCLIActivationClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalCLIActivationClient.swift; sourceTree = ""; }; + BEEF51010000000000000002 /* PrivateFileStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PrivateFileStore.swift; sourceTree = ""; }; + D35C0DE10000000000000002 /* DesktopShortcutSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DesktopShortcutSupport.swift; sourceTree = ""; }; + B0A5E0010000000000000002 /* BrowserSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BrowserSupport.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -57,12 +73,20 @@ EF52D9684360646294953B1F /* App.swift */, 49BEF7A09915818A58E5A2EF /* Bridge.swift */, 62628C73FF1A69BD00091DA1 /* BrowserService.swift */, + B0A5E0010000000000000002 /* BrowserSupport.swift */, 63EF8969384F356D88104E1B /* EmbeddedProxyServer.swift */, FDE1137739EE1ED6755359FA /* EmbeddedProxyService.swift */, 5851AA5FE9CF461DF6F42203 /* ScannerService.swift */, 691AB78EB353B35D93A6A47B /* Services.swift */, C4FBC43C303E1D1048DBEFEA /* SocketService.swift */, + A11C0CA20000000000000002 /* LocalCLIActivationClient.swift */, + A11C0CA10000000000000002 /* LocalCLIService.swift */, + BEEF51010000000000000002 /* PrivateFileStore.swift */, 80EF74D452998AB632E94C07 /* WebView.swift */, + 0F9576C42983458B9BA108E1 /* ShellularWindow.swift */, + A1C4128092F348E8B821EBB2 /* NativeThemeColor.swift */, + C0A1B2C3D4E5F60718293042 /* ContextMenuSupport.swift */, + D35C0DE10000000000000002 /* DesktopShortcutSupport.swift */, 93E2D0EAC882DBBEB887234B /* Assets.xcassets */, 67C797A43F6F06EB8F3B1E75 /* bundle */, 121224A630036B7700277573 /* Shellular.icon */, @@ -177,12 +201,20 @@ ECAC48CB6FFEC2C17B0DFEF3 /* App.swift in Sources */, 46266FB94C919F94022C7994 /* Bridge.swift in Sources */, 15A677D8DB0E07AEA33762BB /* BrowserService.swift in Sources */, + B0A5E0010000000000000001 /* BrowserSupport.swift in Sources */, 41595B5E8684E4075170609C /* EmbeddedProxyServer.swift in Sources */, 41581F5C82786E6EA18A95AE /* EmbeddedProxyService.swift in Sources */, F3F28883E3832A8790FA187B /* ScannerService.swift in Sources */, + A11C0CA20000000000000001 /* LocalCLIActivationClient.swift in Sources */, + A11C0CA10000000000000001 /* LocalCLIService.swift in Sources */, + BEEF51010000000000000001 /* PrivateFileStore.swift in Sources */, 6277848D9BFDE2DD87D7382C /* Services.swift in Sources */, EDEC0196FD87CAA3BC2C9622 /* SocketService.swift in Sources */, 8F54DD3E90571D5CA29A106F /* WebView.swift in Sources */, + D39104FC72E04777A6E18331 /* ShellularWindow.swift in Sources */, + F28B7996E66040FD98C7260A /* NativeThemeColor.swift in Sources */, + C0A1B2C3D4E5F60718293041 /* ContextMenuSupport.swift in Sources */, + D35C0DE10000000000000001 /* DesktopShortcutSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -256,7 +288,7 @@ ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = Shellular; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = shellular/shellular.entitlements; + CODE_SIGN_ENTITLEMENTS = shellular/shellular.direct.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 34; @@ -269,6 +301,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.foxbiz.shellular; PRODUCT_NAME = Shellular; SDKROOT = macosx; + SHELLULAR_DEV_CLI_DIRECTORY = "$(SRCROOT)/../../../packages/cli"; SWIFT_VERSION = 5.0; }; name = Debug; @@ -345,6 +378,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.foxbiz.shellular; PRODUCT_NAME = Shellular; SDKROOT = macosx; + SHELLULAR_DEV_CLI_DIRECTORY = ""; SWIFT_VERSION = 5.0; }; name = Release; diff --git a/platforms/macos/shellular/App.swift b/platforms/macos/shellular/App.swift index e131576..1995dd2 100644 --- a/platforms/macos/shellular/App.swift +++ b/platforms/macos/shellular/App.swift @@ -1,34 +1,364 @@ -import SwiftUI import AppKit import UserNotifications +@main final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { static weak var shared: AppDelegate? - var deepLinkHandler: ((URL) -> Void)? - func applicationDidFinishLaunching(_ notification: Notification) { + private static let retainedDelegate = AppDelegate() + + static func main() { + let application = NSApplication.shared + application.delegate = retainedDelegate + application.setActivationPolicy(.regular) + application.run() + } + + private var mainWindowController: NSWindowController? + private var menuController: ApplicationMenuController? + private var pendingDeepLinks: [URL] = [] + var deepLinkHandler: ((URL) -> Void)? { + didSet { + guard let deepLinkHandler, !pendingDeepLinks.isEmpty else { return } + let pending = pendingDeepLinks + pendingDeepLinks.removeAll() + pending.forEach(deepLinkHandler) + } + } + + func applicationWillFinishLaunching(_ notification: Notification) { Self.shared = self + NSWindow.allowsAutomaticWindowTabbing = false + let menuController = ApplicationMenuController() + self.menuController = menuController + menuController.install() + } + + func applicationDidFinishLaunching(_ notification: Notification) { UNUserNotificationCenter.current().delegate = self + + let controller = WebViewController() + let window = ShellularWindow( + contentRect: NSRect(x: 0, y: 0, width: 1100, height: 760), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.contentViewController = controller + window.title = "Home" + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + window.tabbingMode = .disallowed + window.minSize = NSSize(width: 760, height: 560) + window.isReleasedWhenClosed = false + BrowserLiveResizePolicy.apply(to: window) + window.setFrameAutosaveName("ShellularMainWindow") + + let windowController = NSWindowController(window: window) + mainWindowController = windowController + if !window.setFrameUsingName("ShellularMainWindow") { + window.center() + } + windowController.showWindow(nil) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + guard !flag, let window = mainWindowController?.window else { return true } + window.makeKeyAndOrderFront(nil) + return true + } + + func updateDesktopShortcutContext( + _ contextualNew: String?, + shortcuts: [String: Any]? + ) { + menuController?.updateShortcuts( + contextualNew: contextualNew, + shortcuts: DesktopShortcutCatalog.resolved(payload: shortcuts) + ) } - func application(_ application: NSApplication, open urls: [URL]) { urls.forEach { deepLinkHandler?($0) } } - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { [.banner, .sound] } - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + + func application(_ application: NSApplication, open urls: [URL]) { + guard let deepLinkHandler else { + pendingDeepLinks.append(contentsOf: urls) + return + } + urls.forEach(deepLinkHandler) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [.banner, .sound] + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { NotificationService.shared.handleTap(response.notification.request.identifier) } } -@main struct shellularApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate - var body: some Scene { - WindowGroup { WebContainer().frame(minWidth: 760, minHeight: 560) } - .defaultSize(width: 1100, height: 760) - .commands { - CommandGroup(after: .newItem) { Button("Open File…") { NotificationCenter.default.post(name: .openFile, object: nil) }.keyboardShortcut("o") } - CommandGroup(after: .sidebar) { Button("Reload") { NotificationCenter.default.post(name: .reloadWebView, object: nil) }.keyboardShortcut("r") } +private final class ApplicationMenuController: NSObject { + private var commandItems: [String: [NSMenuItem]] = [:] + private var contextualNew = "new-file" + private var shortcuts = DesktopShortcutCatalog.defaults + + func install() { + let mainMenu = NSMenu(title: "Main Menu") + mainMenu.addItem(rootItem(title: "Shellular", submenu: applicationMenu())) + mainMenu.addItem(rootItem(title: "File", submenu: fileMenu())) + mainMenu.addItem(rootItem(title: "Edit", submenu: editMenu())) + mainMenu.addItem(rootItem(title: "View", submenu: viewMenu())) + mainMenu.addItem(rootItem(title: "Window", submenu: windowMenu())) + mainMenu.addItem(rootItem(title: "Help", submenu: helpMenu())) + #if DEBUG + mainMenu.addItem(rootItem(title: "Debug", submenu: debugMenu())) + #endif + NSApp.mainMenu = mainMenu + } + + private func applicationMenu() -> NSMenu { + let menu = NSMenu(title: "Shellular") + menu.addItem(actionItem("About Shellular", action: #selector(showAbout))) + menu.addItem(.separator()) + menu.addItem(commandItem("Settings…", shortcut: DesktopShortcutCatalog.settings)) + menu.addItem(.separator()) + + let services = NSMenu(title: "Services") + let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") + servicesItem.submenu = services + menu.addItem(servicesItem) + NSApp.servicesMenu = services + + menu.addItem(.separator()) + menu.addItem(systemItem("Hide Shellular", action: #selector(NSApplication.hide(_:)), key: "h")) + menu.addItem(systemItem( + "Hide Others", + action: #selector(NSApplication.hideOtherApplications(_:)), + key: "h", + modifiers: [.command, .option] + )) + menu.addItem(systemItem("Show All", action: #selector(NSApplication.unhideAllApplications(_:)))) + menu.addItem(.separator()) + menu.addItem(systemItem("Quit Shellular", action: #selector(NSApplication.terminate(_:)), key: "q")) + return menu + } + + private func fileMenu() -> NSMenu { + let menu = NSMenu(title: "File") + let newFile = commandItem("New File", command: "new-file") + let newChat = commandItem("New Chat", command: "new-chat") + menu.addItem(newFile) + menu.addItem(newChat) + updateShortcuts(contextualNew: nil, shortcuts: shortcuts) + menu.addItem(commandItem( + "New Terminal", + shortcut: DesktopShortcutCatalog.newTerminal + )) + menu.addItem(.separator()) + menu.addItem(commandItem("Open File…", shortcut: DesktopShortcutCatalog.openFile)) + menu.addItem(commandItem("Open Folder…", command: "open-folder")) + menu.addItem(.separator()) + menu.addItem(commandItem("Save", shortcut: DesktopShortcutCatalog.save)) + menu.addItem(.separator()) + menu.addItem(commandItem("Close Tab", shortcut: DesktopShortcutCatalog.closeTab)) + return menu + } + + private func editMenu() -> NSMenu { + let menu = NSMenu(title: "Edit") + menu.addItem(commandItem("Undo", shortcut: DesktopShortcutCatalog.undo)) + menu.addItem(commandItem("Redo", shortcut: DesktopShortcutCatalog.redo)) + menu.addItem(.separator()) + menu.addItem(commandItem("Cut", shortcut: DesktopShortcutCatalog.cut)) + menu.addItem(commandItem("Copy", shortcut: DesktopShortcutCatalog.copy)) + menu.addItem(commandItem("Paste", shortcut: DesktopShortcutCatalog.paste)) + menu.addItem(commandItem("Select All", shortcut: DesktopShortcutCatalog.selectAll)) + return menu + } + + private func viewMenu() -> NSMenu { + let menu = NSMenu(title: "View") + let sidebar = commandItem("Toggle Sidebar", shortcut: DesktopShortcutCatalog.toggleSidebar) + sidebar.image = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: nil) + menu.addItem(sidebar) + menu.addItem(commandItem( + "Toggle Terminal", + shortcut: DesktopShortcutCatalog.toggleTerminal + )) + menu.addItem(.separator()) + menu.addItem(commandItem( + "Explorer", + shortcut: DesktopShortcutCatalog.showExplorer + )) + menu.addItem(commandItem( + "Search", + shortcut: DesktopShortcutCatalog.projectSearch + )) + menu.addItem(commandItem( + "Source Control", + shortcut: DesktopShortcutCatalog.showSourceControl + )) + menu.addItem(.separator()) + let ports = commandItem("Ports", command: "ports") + ports.image = NSImage(systemSymbolName: "network", accessibilityDescription: nil) + menu.addItem(ports) + let monitor = commandItem("System Monitor", command: "system-monitor") + monitor.image = NSImage(systemSymbolName: "chart.xyaxis.line", accessibilityDescription: nil) + menu.addItem(monitor) + menu.addItem(.separator()) + menu.addItem(responderItem( + "Enter Full Screen", + selector: #selector(NSWindow.toggleFullScreen(_:)), + key: "f", + modifiers: [.command, .control] + )) + return menu + } + + private func windowMenu() -> NSMenu { + let menu = NSMenu(title: "Window") + menu.addItem(responderItem("Minimize", selector: #selector(NSWindow.performMiniaturize(_:)), key: "m")) + menu.addItem(responderItem("Zoom", selector: #selector(NSWindow.performZoom(_:)))) + menu.addItem(.separator()) + menu.addItem(responderItem("Bring All to Front", selector: #selector(NSApplication.arrangeInFront(_:)))) + NSApp.windowsMenu = menu + return menu + } + + private func helpMenu() -> NSMenu { + let menu = NSMenu(title: "Help") + menu.addItem(commandItem("Shellular Help", command: "help")) + menu.addItem(commandItem("Reach Out", command: "reach-out")) + menu.addItem(commandItem("About", command: "about")) + NSApp.helpMenu = menu + return menu + } + + #if DEBUG + private func debugMenu() -> NSMenu { + let menu = NSMenu(title: "Debug") + menu.addItem(actionItem( + "Reload Workbench", + action: #selector(reloadWorkbench), + key: "r", + modifiers: [.command, .option] + )) + return menu + } + #endif + + private func rootItem(title: String, submenu: NSMenu) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.submenu = submenu + return item + } + + private func commandItem( + _ title: String, + command: String, + key: String = "", + modifiers: NSEvent.ModifierFlags = [.command] + ) -> NSMenuItem { + let item = actionItem(title, action: #selector(runDesktopCommand(_:)), key: key, modifiers: modifiers) + item.representedObject = command + commandItems[command, default: []].append(item) + return item + } + + private func commandItem( + _ title: String, + shortcut: DesktopNativeShortcut + ) -> NSMenuItem { + commandItem( + title, + command: shortcut.command, + key: shortcut.key, + modifiers: shortcut.modifiers + ) + } + + func updateShortcuts( + contextualNew: String?, + shortcuts: [String: DesktopNativeShortcut] + ) { + self.contextualNew = contextualNew == "new-chat" ? "new-chat" : "new-file" + self.shortcuts = shortcuts + for (command, items) in commandItems { + let shortcut: DesktopNativeShortcut? + if command == "new-file" || command == "new-chat" { + shortcut = DesktopShortcutCatalog.contextualShortcut( + for: command, + contextualCommand: self.contextualNew, + shortcuts: shortcuts + ) + } else { + shortcut = shortcuts[command] + } + for item in items { + item.keyEquivalent = shortcut?.key ?? "" + item.keyEquivalentModifierMask = shortcut?.modifiers ?? [] } + } + } + + private func responderItem( + _ title: String, + selector: Selector, + key: String = "", + modifiers: NSEvent.ModifierFlags = [.command] + ) -> NSMenuItem { + let item = NSMenuItem(title: title, action: selector, keyEquivalent: key) + item.keyEquivalentModifierMask = key.isEmpty ? [] : modifiers + item.target = nil + return item + } + + private func systemItem( + _ title: String, + action: Selector, + key: String = "", + modifiers: NSEvent.ModifierFlags = [.command] + ) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: key) + item.keyEquivalentModifierMask = key.isEmpty ? [] : modifiers + item.target = NSApp + return item + } + + private func actionItem( + _ title: String, + action: Selector, + key: String = "", + modifiers: NSEvent.ModifierFlags = [.command] + ) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: key) + item.keyEquivalentModifierMask = key.isEmpty ? [] : modifiers + item.target = self + return item + } + + @objc private func runDesktopCommand(_ sender: NSMenuItem) { + guard let command = sender.representedObject as? String else { return } + NativeService.sendDesktopCommand(command) + } + + @objc private func showAbout() { + NSApp.orderFrontStandardAboutPanel(nil) + } + + #if DEBUG + @objc private func reloadWorkbench() { + NotificationCenter.default.post(name: .reloadWebView, object: nil) } + #endif } extension Notification.Name { - static let openFile = Notification.Name("shellular.openFile") static let reloadWebView = Notification.Name("shellular.reloadWebView") } diff --git a/platforms/macos/shellular/Bridge.swift b/platforms/macos/shellular/Bridge.swift index 1ae18f9..96ff251 100644 --- a/platforms/macos/shellular/Bridge.swift +++ b/platforms/macos/shellular/Bridge.swift @@ -28,11 +28,34 @@ final class Bridge: NSObject, WKScriptMessageHandler { private var services: [String: ServiceProtocol] = [:] func setup(webView: WKWebView, viewController: WebViewController) { self.webView = webView; self.viewController = viewController - services = ["Native":NativeService(bridge:self), "FileHandler":FileHandlerService(bridge:self), "Device":DeviceService(bridge:self), "Dialog":DialogService(bridge:self), "Encryption":EncryptionService(bridge:self), "Notification":NotificationService.sharedInstance(bridge:self), "SecureStore":SecureStoreService(bridge:self), "Scanner":ScannerService(bridge:self), "Browser":BrowserService(bridge:self), "EmbeddedProxy":EmbeddedProxyService(bridge:self), "SocketService":SocketService(bridge:self)] + services = ["Native":NativeService(bridge:self), "ContextMenu":ContextMenuService(bridge:self), "FileHandler":FileHandlerService(bridge:self), "Device":DeviceService(bridge:self), "Dialog":DialogService(bridge:self), "Encryption":EncryptionService(bridge:self), "Notification":NotificationService.sharedInstance(bridge:self), "SecureStore":SecureStoreService(bridge:self), "Scanner":ScannerService(bridge:self), "Browser":BrowserService(bridge:self), "EmbeddedProxy":EmbeddedProxyService(bridge:self), "SocketService":SocketService(bridge:self), "LocalCLI":LocalCLIService(bridge:self)] } func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.frameInfo.isMainFrame, isTrusted(origin: message.frameInfo.securityOrigin) else { + NSLog("Rejected bridge call from %@://%@", message.frameInfo.securityOrigin.protocol, message.frameInfo.securityOrigin.host) + return + } guard let body = message.body as? [String:Any], let service = body["service"] as? String, let action = body["action"] as? String, let string = body["args"] as? String, let id = body["id"] as? Int, let target = services[service] else { return } let args = (try? JSONSerialization.jsonObject(with: Data(string.utf8))) as? [Any] ?? [] target.exec(action: action, args: args, callback: Callback(id: id, webView: webView)) } + + private func isTrusted(origin: WKSecurityOrigin) -> Bool { + if origin.protocol == "shellular" && origin.host == "localhost" { return true } + #if DEBUG + guard origin.protocol == "http" || origin.protocol == "https" else { return false } + return isLocalDevelopmentHost(origin.host) + #else + return false + #endif + } + + private func isLocalDevelopmentHost(_ host: String) -> Bool { + if host == "localhost" || host == "127.0.0.1" || host == "::1" { return true } + let parts = host.split(separator: ".").compactMap { Int($0) } + guard parts.count == 4 else { return false } + return parts[0] == 10 || + (parts[0] == 172 && (16...31).contains(parts[1])) || + (parts[0] == 192 && parts[1] == 168) + } } diff --git a/platforms/macos/shellular/BrowserService.swift b/platforms/macos/shellular/BrowserService.swift index cafbde4..9bb0731 100644 --- a/platforms/macos/shellular/BrowserService.swift +++ b/platforms/macos/shellular/BrowserService.swift @@ -1,44 +1,2083 @@ import AppKit import AuthenticationServices +import CryptoKit +import Security +import UniformTypeIdentifiers import WebKit +private struct BrowserConnectionContext: Equatable { + let status: String + let transport: String? + let hostId: String? + let hostName: String? + let tcpTunnelVersion: Int? + + init(_ value: Any?) { + let data = value as? [String: Any] ?? [:] + status = data["status"] as? String ?? "disconnected" + transport = data["transport"] as? String + hostId = data["hostId"] as? String + hostName = data["hostName"] as? String + tcpTunnelVersion = data["tcpTunnelVersion"] as? Int + } + + var canProxyRemote: Bool { + transport == "remote" && status == "connected" && hostId != nil + } + + var canTunnelRemote: Bool { + canProxyRemote && tcpTunnelVersion == 1 + } +} + final class BrowserService: BaseService, ASWebAuthenticationPresentationContextProviding { private var authSession: ASWebAuthenticationSession? - private var browserWindows: [BrowserWindowController] = [] - override func exec(action:String,args:[Any],callback:Callback){switch action{case"open":open(args,html:false,callback);case"openHTML":open(args,html:true,callback);case"openForAuth":auth(args,callback);default:callback.error("Unknown action: \(action)")}} - private func open(_ args:[Any],html:Bool,_ callback:Callback){DispatchQueue.main.async{let controller=BrowserWindowController();if html{controller.loadHTML(args.first as? String ?? "")}else if let s=args.first as? String,let u=URL(string:s){controller.load(u)}else{return callback.error("Invalid URL")};controller.onClose = { [weak self, weak controller] in if let controller { self?.browserWindows.removeAll { $0 === controller } } };self.browserWindows.append(controller);controller.showWindow(nil);NSApp.activate(ignoringOtherApps:true);callback.success()}} - private func auth(_ args:[Any],_ callback:Callback){ - guard let s=args.first as? String,let url=URL(string:s)else{return callback.error("Invalid URL")} - let scheme=args[safe:1] as? String ?? "shellular" + private var browserWindow: BrowserWindowController? + private var context = BrowserConnectionContext(nil) + + override func exec(action: String, args: [Any], callback: Callback) { + switch action { + case "open": open(args, html: false, callback) + case "openHTML": open(args, html: true, callback) + case "setContext": + context = BrowserConnectionContext(args.first) + DispatchQueue.main.async { self.browserWindow?.updateContext(self.context) } + callback.success() + case "setTheme": + let theme = args.first as? [String: Any] ?? [:] + let homeDocument = args[safe: 1] as? String ?? "" + DispatchQueue.main.async { + self.browserWindow?.updateTheme(theme, homeDocument: homeDocument) + } + callback.success() + case "openForAuth": auth(args, callback) + default: callback.error("Unknown action: \(action)") + } + } + + private func open(_ args: [Any], html: Bool, _ callback: Callback) { + let content = args.first as? String + let theme = args[safe: 1] as? [String: Any] ?? [:] + context = BrowserConnectionContext(args[safe: 2]) + let homeDocument = args[safe: 3] as? String ?? "" + DispatchQueue.main.async { + let controller: BrowserWindowController + if let existing = self.browserWindow { + controller = existing + controller.updateContext(self.context) + controller.updateTheme(theme, homeDocument: homeDocument) + } else { + controller = BrowserWindowController( + mainWebView: self.webView, + context: self.context, + theme: theme, + homeDocument: homeDocument + ) + controller.onClose = { [weak self, weak controller] in + if self?.browserWindow === controller { self?.browserWindow = nil } + } + controller.onRequestPopOut = { [weak self, weak controller] in + guard let self, let controller else { return } + controller.presentInWindow() + self.viewController?.hideBrowserSidebar() + } + controller.onRequestDock = { [weak self, weak controller] reveal in + guard let self, let controller, let viewController = self.viewController else { return } + controller.presentInSidebar() + viewController.showBrowserSidebar(controller.browserView) + if !reveal { viewController.hideBrowserSidebar() } + } + controller.onRequestCloseSidebar = { [weak self] in + self?.viewController?.hideBrowserSidebar() + } + self.browserWindow = controller + } + + if html { + controller.openHTML(content ?? "") + } else if let content, !content.isEmpty { + guard let url = BrowserAddressResolver.resolve(content) else { + return callback.error("Invalid URL") + } + controller.openURL(url) + } else { + controller.openOrRestoreHome() + } + if controller.isPresentedInWindow { + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + } else if let viewController = self.viewController { + controller.presentInSidebar() + viewController.showBrowserSidebar(controller.browserView) + viewController.view.window?.makeKeyAndOrderFront(nil) + } + NSApp.activate(ignoringOtherApps: true) + callback.success() + } + } + + private func auth(_ args: [Any], _ callback: Callback) { + guard let value = args.first as? String, let url = URL(string: value) else { + return callback.error("Invalid URL") + } + let scheme = args[safe: 1] as? String ?? "shellular" DispatchQueue.main.async { self.webView?.evaluateJavaScript("window.dispatchEvent(new CustomEvent('browserwillopen'))") self.authSession?.cancel() - self.authSession=ASWebAuthenticationSession(url:url,callbackURLScheme:scheme){url,error in + self.authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { url, error in DispatchQueue.main.async { - self.authSession=nil + self.authSession = nil self.webView?.evaluateJavaScript("window.dispatchEvent(new CustomEvent('browserdidclose'))") guard let url else { return callback.error(error?.localizedDescription ?? "Authentication cancelled") } - guard url.scheme == scheme, url.host == "auth-callback" else { return callback.error("Authentication returned an invalid callback URL") } - var params:[String:String]=[:] - URLComponents(url:url,resolvingAgainstBaseURL:false)?.queryItems?.forEach { params[$0.name]=$0.value ?? "" } - guard let data=try? JSONSerialization.data(withJSONObject:["url":url.absoluteString,"params":params]),let json=String(data:data,encoding:.utf8) else { return callback.error("Could not decode authentication callback") } + guard url.scheme == scheme, url.host == "auth-callback" else { + return callback.error("Authentication returned an invalid callback URL") + } + var params: [String: String] = [:] + URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.forEach { + params[$0.name] = $0.value ?? "" + } + guard let data = try? JSONSerialization.data(withJSONObject: ["url": url.absoluteString, "params": params]), + let json = String(data: data, encoding: .utf8) else { + return callback.error("Could not decode authentication callback") + } callback.success(json) } } - self.authSession?.presentationContextProvider=self - self.authSession?.prefersEphemeralWebBrowserSession=false - if self.authSession?.start() != true { self.authSession=nil; callback.error("Unable to start authentication session") } + self.authSession?.presentationContextProvider = self + self.authSession?.prefersEphemeralWebBrowserSession = false + if self.authSession?.start() != true { + self.authSession = nil + callback.error("Unable to start authentication session") + } + } + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + viewController?.view.window ?? NSApp.keyWindow! + } +} + +private final class BrowserWindow: NSWindow { + weak var commandHandler: BrowserWindowController? + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if commandHandler?.handleKeyEquivalent(event) == true { return true } + return super.performKeyEquivalent(with: event) + } +} + +private struct SavedBrowserTab: Codable { + let id: String + let url: String + let title: String + let remoteHostId: String? +} + +private struct SavedBrowserSession: Codable { + let tabs: [SavedBrowserTab] + let activeId: String? +} + +private enum BrowserSecurityState { + case internalPage + case secure + case notSecure + case unsafeCertificate +} + +private enum BrowserDocumentKind: Equatable { + case external + case shellular(String) + case browserUI + + var isShellularOwned: Bool { + switch self { + case .shellular, .browserUI: return true + case .external: return false + } + } +} + +private struct PendingCertificateWarning { + let token: String + let exception: BrowserTrustException + let url: URL + let details: String +} + +private struct BrowserCertificateMetadata { + let exception: BrowserTrustException + let subject: String + let details: String +} + +private final class BrowserTab { + let id: String + let webView: BrowserInspectableWebView + let viewportHost: BrowserViewportHostView + var displayURL: String + var title: String + var remoteHostId: String? + var remoteTunnelPort: Int? + var remoteTunnelHost: String? + var ephemeral = false + var needsRestoreLoad = false + var internalRequestId: UUID? + var securityState: BrowserSecurityState = .internalPage + var certificateWarning: PendingCertificateWarning? + var activeTrustException: BrowserTrustException? + var lastFailedURL: URL? + var documentKind: BrowserDocumentKind = .external + var developerTools: BrowserDeveloperToolsState + var observations: [NSKeyValueObservation] = [] + + init( + id: String = UUID().uuidString, + webView: BrowserInspectableWebView, + viewportHost: BrowserViewportHostView, + displayURL: String, + title: String, + remoteHostId: String? = nil, + developerToolsPanelPercent: CGFloat + ) { + self.id = id + self.webView = webView + self.viewportHost = viewportHost + self.displayURL = displayURL + self.title = title + self.remoteHostId = remoteHostId + developerTools = BrowserDeveloperToolsState( + panelPercent: developerToolsPanelPercent + ) + } +} + +private final class BrowserWindowController: + NSWindowController, + NSWindowDelegate, + WKNavigationDelegate, + WKUIDelegate, + WKDownloadDelegate, + WKScriptMessageHandler, + NSTextFieldDelegate { + private static let sessionKey = "shellular.browser.session.v1" + private weak var mainWebView: WKWebView? + private var context: BrowserConnectionContext + private var theme: [String: Any] + private var homeDocument: String + private var tabs: [BrowserTab] = [] + private var activeId: String? + private var restored = false + private var closedTabs: [SavedBrowserTab] = [] + private let assetHandler = ShellularAssetSchemeHandler() + private let developerToolsUserScript: WKUserScript? + private let developerToolsMessageHandler = BrowserWeakScriptMessageHandler() + private let browserUserContentController = WKUserContentController() + private let developerToolsPanelSizeStore: BrowserDeveloperToolsPanelSizeStore + private var preferredDeveloperToolsPanelPercent: CGFloat + private let trustExceptions = BrowserTrustExceptionStore() + private let warningTokens = BrowserOneTimeTokenStore() + private let trustEvaluationQueue = DispatchQueue( + label: "org.shellular.browser.trust-evaluation", + qos: .userInitiated + ) + + private let windowHostView = NSView() + private let rootView = NSView() + private let resizeUnderlay = BrowserResizeUnderlayView() + private let rootStack = NSStackView() + private let tabHeader = BrowserTabHeaderView() + private let toolbar = NSStackView() + private let backButton = NSButton() + private let forwardButton = NSButton() + private let reloadButton = NSButton() + private let homeButton = NSButton() + private let addressBar = BrowserAddressBar() + private let developerToolsButton = NSButton() + private let moreButton = NSButton() + private let progress = NSProgressIndicator() + private let contentViewContainer = NSView() + private var isEditingAddress = false + private var addressValidationError = false + private var keyMonitor: Any? + private var palette = BrowserChromePalette([:]) + var onClose: (() -> Void)? + var onRequestPopOut: (() -> Void)? + var onRequestDock: ((Bool) -> Void)? + var onRequestCloseSidebar: (() -> Void)? + private var presentation = BrowserPresentationState() + var isPresentedInWindow: Bool { presentation.value == .window } + var browserView: NSView { rootView } + private var addressField: NSTextField { addressBar.textField } + private var tabSearchButton: NSButton { tabHeader.tabListButton } + + init(mainWebView: WKWebView?, context: BrowserConnectionContext, theme: [String: Any], homeDocument: String) { + let panelSizeStore = BrowserDeveloperToolsPanelSizeStore() + let panelPercent = panelSizeStore.load() + developerToolsPanelSizeStore = panelSizeStore + preferredDeveloperToolsPanelPercent = panelPercent + let developerToolsRuntime = BrowserDeveloperToolsScript.loadRuntime() + developerToolsUserScript = developerToolsRuntime.map { + BrowserDeveloperToolsScript.makeUserScript( + erudaSource: $0, + panelPercent: panelPercent + ) + } + self.mainWebView = mainWebView + self.context = context + self.theme = theme + self.homeDocument = homeDocument.isEmpty ? Self.fallbackHomeHTML : homeDocument + let window = BrowserWindow( + contentRect: NSRect(x: 0, y: 0, width: 1120, height: 760), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + super.init(window: window) + if let developerToolsUserScript { + browserUserContentController.addUserScript(developerToolsUserScript) + developerToolsMessageHandler.delegate = self + browserUserContentController.add( + developerToolsMessageHandler, + name: BrowserDeveloperToolsScript.messageHandlerName + ) + } + if developerToolsRuntime == nil { + let resourcePath = Bundle.main.resourceURL?.path ?? "" + NSLog( + "[Shellular Developer Tools] console.js is missing from %@; embedded developer tools are disabled", + resourcePath + ) + } + window.commandHandler = self + window.title = "Shellular Browser" + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = false + BrowserLiveResizePolicy.apply(to: window) + window.center() + window.delegate = self + setupUI() + applyTheme(theme) + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, + !self.isPresentedInWindow, + event.window === self.rootView.window, + !self.rootView.isHiddenOrHasHiddenAncestor, + self.browserHasKeyboardFocus, + self.handleSidebarKeyEquivalent(event) else { return event } + return nil + } + } + + required init?(coder: NSCoder) { fatalError() } + + deinit { + if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } + browserUserContentController.removeScriptMessageHandler( + forName: BrowserDeveloperToolsScript.messageHandlerName + ) + trustExceptions.removeAll() + EmbeddedProxyServer.shared.cancelAllPending() + EmbeddedProxyServer.shared.stopAll() + } + + func updateContext(_ context: BrowserConnectionContext) { + let changedHost = self.context.hostId != context.hostId || + self.context.transport != context.transport + let changedTunnelCapability = + self.context.tcpTunnelVersion != context.tcpTunnelVersion + let lostRemoteConnection = self.context.canProxyRemote && !context.canProxyRemote + self.context = context + if changedHost || changedTunnelCapability || lostRemoteConnection { + EmbeddedProxyServer.shared.cancelAllPending() + EmbeddedProxyServer.shared.stopAll() + for tab in tabs { + tab.remoteTunnelPort = nil + tab.remoteTunnelHost = nil + } + if let tab = activeTab, let remoteHostId = tab.remoteHostId, remoteHostId != context.hostId { + showConnectionError(tab, expectedHostId: remoteHostId) + } + } + } + + func updateTheme(_ value: [String: Any], homeDocument: String) { + if !homeDocument.isEmpty { self.homeDocument = homeDocument } + applyTheme(value) + } + + private func applyTheme(_ value: [String: Any]) { + theme = value + palette = BrowserChromePalette(value) + window?.backgroundColor = palette.primary + windowHostView.layer?.backgroundColor = palette.primary.cgColor + rootView.layer?.backgroundColor = palette.primary.cgColor + contentViewContainer.layer?.backgroundColor = palette.primary.cgColor + resizeUnderlay.apply(content: palette.primary, chrome: palette.chromeBackground) + tabHeader.applyPalette(palette) + toolbar.layer?.backgroundColor = palette.primary.cgColor + addressBar.applyPalette(palette) + addressBar.setValidationError(addressValidationError) + progress.contentFilters = [] + if activeTab != nil { refreshChrome() } + else { syncTabStrip() } + for tab in tabs { + tab.webView.underPageBackgroundColor = palette.primary + tab.viewportHost.applyBackgroundColor(palette.primary) + if tab.documentKind.isShellularOwned { + applyTheme(to: tab) + } + } + } + + private func applyTheme(to tab: BrowserTab) { + guard tab.documentKind.isShellularOwned, + let script = BrowserThemeScript.make(from: theme) else { return } + tab.webView.evaluateJavaScript(script, completionHandler: nil) + } + + func openOrRestoreHome() { + if !restored { + restored = true + restoreSession() + } + if tabs.isEmpty { newHomeTab() } + else if let activeId { selectTab(activeId) } + } + + func openURL(_ url: URL) { + if !restored { + restored = true + restoreSession() + } + if let tab = activeTab { + navigate(tab, to: url) + return + } + let tab = makeTab(url: url.absoluteString, title: url.host ?? "New Tab") + tabs.append(tab) + selectTab(tab.id) + navigate(tab, to: url) + } + + func openHTML(_ html: String) { + if !restored { + restored = true + restoreSession() + } + if let tab = activeTab { + tab.ephemeral = true + tab.documentKind = .external + tab.displayURL = "" + tab.title = "Document" + tab.webView.loadHTMLString(html, baseURL: nil) + refreshChrome() + return + } + let tab = makeTab(url: "", title: "Document") + tab.ephemeral = true + tab.documentKind = .external + tabs.append(tab) + selectTab(tab.id) + tab.webView.loadHTMLString(html, baseURL: nil) + } + + private func setupUI() { + guard let window else { return } + windowHostView.wantsLayer = true + windowHostView.layerContentsRedrawPolicy = .duringViewResize + window.contentView = windowHostView + rootView.wantsLayer = true + rootView.layerContentsRedrawPolicy = .duringViewResize + BrowserViewAttachment.attach(rootView, to: windowHostView) + + resizeUnderlay.frame = rootView.bounds + resizeUnderlay.autoresizingMask = [.width, .height] + rootView.addSubview(resizeUnderlay) + + BrowserRootLayout.configure(stack: rootStack, content: contentViewContainer) + rootStack.translatesAutoresizingMaskIntoConstraints = false + rootView.addSubview(rootStack) + NSLayoutConstraint.activate([ + rootStack.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + rootStack.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + rootStack.topAnchor.constraint(equalTo: rootView.topAnchor), + rootStack.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + ]) + + configureIconButton( + tabHeader.addButton, + symbol: "plus", + help: "New Tab", + action: #selector(newTabAction), + fixedSize: false + ) + configureIconButton( + tabHeader.tabListButton, + symbol: "chevron.down", + help: "Show Tabs", + action: #selector(searchTabsAction), + fixedSize: false + ) + configureIconButton( + tabHeader.presentationButton, + symbol: "arrow.up.right.square", + help: "Open in Separate Window", + action: #selector(presentationAction), + fixedSize: false + ) + configureIconButton( + tabHeader.closeButton, + symbol: "xmark", + help: "Close Browser Sidebar", + action: #selector(closeSidebarAction), + fixedSize: false + ) + rootStack.addArrangedSubview(tabHeader) + + toolbar.orientation = .horizontal + toolbar.alignment = .centerY + toolbar.distribution = .fill + toolbar.spacing = 3 + toolbar.edgeInsets = NSEdgeInsets(top: 5, left: 8, bottom: 5, right: 8) + toolbar.wantsLayer = true + toolbar.heightAnchor.constraint(equalToConstant: 46).isActive = true + rootStack.addArrangedSubview(toolbar) + configureIconButton(backButton, symbol: "chevron.left", help: "Back", action: #selector(backAction)) + configureIconButton(forwardButton, symbol: "chevron.right", help: "Forward", action: #selector(forwardAction)) + configureIconButton(reloadButton, symbol: "arrow.clockwise", help: "Reload", action: #selector(reloadAction)) + configureIconButton(homeButton, symbol: "house", help: "Home", action: #selector(homeAction)) + toolbar.addArrangedSubview(backButton) + toolbar.addArrangedSubview(forwardButton) + toolbar.addArrangedSubview(reloadButton) + toolbar.addArrangedSubview(homeButton) + addressField.delegate = self + addressField.target = self + addressField.action = #selector(addressAction) + addressBar.securityButton.target = self + addressBar.securityButton.action = #selector(securityAction) + addressBar.clearButton.target = self + addressBar.clearButton.action = #selector(clearAddressAction) + toolbar.addArrangedSubview(addressBar) + configureIconButton( + developerToolsButton, + symbol: "hammer", + help: "Developer Tools (⌘⌥I or ⌘⇧I)", + action: #selector(developerToolsAction) + ) + configureIconButton( + moreButton, + symbol: "ellipsis.circle", + help: "Browser Options", + action: #selector(moreAction) + ) + toolbar.addArrangedSubview(developerToolsButton) + toolbar.addArrangedSubview(moreButton) + + progress.isIndeterminate = false + progress.minValue = 0 + progress.maxValue = 1 + progress.controlSize = .small + progress.style = .bar + progress.isHidden = true + progress.translatesAutoresizingMaskIntoConstraints = false + progress.heightAnchor.constraint(equalToConstant: 2).isActive = true + rootStack.addArrangedSubview(progress) + + contentViewContainer.wantsLayer = true + contentViewContainer.layerContentsRedrawPolicy = .duringViewResize + contentViewContainer.translatesAutoresizingMaskIntoConstraints = false + rootStack.addArrangedSubview(contentViewContainer) + contentViewContainer.widthAnchor.constraint(equalTo: rootStack.widthAnchor).isActive = true + updatePresentationChrome() + } + + func presentInSidebar() { + window?.orderOut(nil) + presentation.moveToSidebar() + updatePresentationChrome() + } + + func presentInWindow() { + BrowserViewAttachment.attach(rootView, to: windowHostView) + presentation.moveToWindow() + updatePresentationChrome() + rootView.layoutSubtreeIfNeeded() + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + } + + private func updatePresentationChrome() { + tabHeader.applyPresentation(presentation.value) + } + + private func configureIconButton( + _ button: NSButton, + symbol: String, + help: String, + action: Selector, + fixedSize: Bool = true + ) { + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: help) + button.imagePosition = .imageOnly + button.title = "" + button.bezelStyle = .texturedRounded + button.isBordered = false + button.toolTip = help + button.target = self + button.action = action + button.translatesAutoresizingMaskIntoConstraints = false + if fixedSize { + button.widthAnchor.constraint(equalToConstant: 30).isActive = true + button.heightAnchor.constraint(equalToConstant: 30).isActive = true + } + } + + private func makeConfiguration(_ supplied: WKWebViewConfiguration? = nil) -> WKWebViewConfiguration { + let configuration: WKWebViewConfiguration + if let supplied { + configuration = supplied + } else { + configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + configuration.setURLSchemeHandler(assetHandler, forURLScheme: "shellular") + } + configuration.userContentController = browserUserContentController + return configuration + } + + private func makeTab( + id: String = UUID().uuidString, + url: String, + title: String, + remoteHostId: String? = nil, + configuration: WKWebViewConfiguration? = nil + ) -> BrowserTab { + let webView = BrowserInspectableWebView( + frame: .zero, + configuration: makeConfiguration(configuration) + ) + webView.navigationDelegate = self + webView.uiDelegate = self + webView.allowsBackForwardNavigationGestures = true + webView.isInspectable = true + webView.embeddedDeveloperToolsAvailable = false + webView.developerToolsVisibility = { [weak self, weak webView] in + self?.tab(containing: webView)?.developerTools.isVisible ?? false + } + webView.elementHighlightVisibility = { [weak self, weak webView] in + self?.tab(containing: webView)?.developerTools.isElementHighlighted ?? false + } + webView.onInspectElement = { [weak self, weak webView] in + guard let self, let tab = self.tab(containing: webView) else { return } + self.inspectElement(in: tab) + } + webView.onClearElementHighlight = { [weak self, weak webView] in + guard let self, let tab = self.tab(containing: webView) else { return } + self.clearElementHighlight(in: tab) + } + webView.onCloseDeveloperTools = { [weak self, weak webView] in + guard let self, let tab = self.tab(containing: webView) else { return } + self.closeDeveloperTools(in: tab) + } + webView.setValue(false, forKey: "drawsBackground") + webView.underPageBackgroundColor = palette.primary + let viewportHost = BrowserViewportHostView(webView: webView) + viewportHost.applyBackgroundColor(palette.primary) + let tab = BrowserTab( + id: id, + webView: webView, + viewportHost: viewportHost, + displayURL: url, + title: title, + remoteHostId: remoteHostId, + developerToolsPanelPercent: preferredDeveloperToolsPanelPercent + ) + if let internalURL = URL(string: url), internalURL.scheme == "shellular" { + tab.documentKind = .shellular(internalURL.host ?? "home") + } + observe(tab) + return tab + } + + private func observe(_ tab: BrowserTab) { + tab.observations = [ + tab.webView.observe(\.title, options: [.new]) { [weak self, weak tab] _, change in + guard let self, let tab, tab.documentKind == .external, + let title = change.newValue ?? nil, !title.isEmpty else { return } + tab.title = title + self.refreshChrome() + self.saveSession() + }, + tab.webView.observe(\.url, options: [.new]) { [weak self, weak tab] _, change in + guard let self, let tab, tab.documentKind == .external, + let url = change.newValue ?? nil, + !url.absoluteString.hasPrefix("about:") else { return } + tab.displayURL = url.absoluteString + self.refreshChrome() + self.saveSession() + }, + tab.webView.observe(\.estimatedProgress, options: [.new]) { [weak self, weak tab] _, change in + guard let self, tab?.id == self.activeId else { return } + self.progress.doubleValue = change.newValue ?? 0 + }, + tab.webView.observe(\.isLoading, options: [.new]) { [weak self, weak tab] _, _ in + guard let self, tab?.id == self.activeId else { return } + self.refreshChrome() + }, + ] + } + + private var activeTab: BrowserTab? { tabs.first { $0.id == activeId } } + private func tab(containing webView: WKWebView?) -> BrowserTab? { + guard let webView else { return nil } + return tabs.first { $0.webView === webView } + } + private var presentationWindow: NSWindow? { rootView.window ?? window } + private var browserHasKeyboardFocus: Bool { + guard let firstResponder = rootView.window?.firstResponder else { return false } + if firstResponder === addressField.currentEditor() { return true } + guard let view = firstResponder as? NSView else { return false } + return view === rootView || view.isDescendant(of: rootView) + } + + private func selectTab(_ id: String) { + guard let tab = tabs.first(where: { $0.id == id }) else { return } + activeId = id + for subview in contentViewContainer.subviews { subview.removeFromSuperview() } + tab.viewportHost.translatesAutoresizingMaskIntoConstraints = false + contentViewContainer.addSubview(tab.viewportHost) + NSLayoutConstraint.activate([ + tab.viewportHost.leadingAnchor.constraint(equalTo: contentViewContainer.leadingAnchor), + tab.viewportHost.trailingAnchor.constraint(equalTo: contentViewContainer.trailingAnchor), + tab.viewportHost.topAnchor.constraint(equalTo: contentViewContainer.topAnchor), + tab.viewportHost.bottomAnchor.constraint(equalTo: contentViewContainer.bottomAnchor), + ]) + rootView.needsLayout = true + rootView.layoutSubtreeIfNeeded() + refreshChrome() + saveSession() + if tab.needsRestoreLoad { + tab.needsRestoreLoad = false + if let url = URL(string: tab.displayURL), url.scheme == "shellular" { + loadInternalPage(tab, name: url.host ?? "home") + } else if let url = URL(string: tab.displayURL) { + navigate(tab, to: url) + } + } + } + + private func newHomeTab() { + let tab = makeTab(url: "shellular://home", title: "Home") + tabs.append(tab) + selectTab(tab.id) + loadHome(tab) + } + + private func closeTab(_ id: String) { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + let tab = tabs[index] + if !tab.ephemeral, !tab.displayURL.isEmpty { + closedTabs.append(SavedBrowserTab(id: tab.id, url: tab.displayURL, title: tab.title, remoteHostId: tab.remoteHostId)) + if closedTabs.count > 10 { closedTabs.removeFirst() } + } + tab.webView.stopLoading() + releaseTunnel(for: tab) + tab.viewportHost.removeFromSuperview() + tabs.remove(at: index) + if tabs.isEmpty { + activeId = nil + newHomeTab() + return + } + if activeId == id { selectTab(tabs[min(index, tabs.count - 1)].id) } + else { syncTabStrip(); saveSession() } + } + + private func syncTabStrip() { + let states = tabs.map { tab in + BrowserTabItemState( + id: tab.id, + title: tab.title, + isSelected: tab.id == activeId, + statusColor: tab.id == activeId && tab.webView.isLoading + ? .systemOrange + : color(for: tab.securityState) + ) + } + tabHeader.reconcileTabs( + states, + onSelect: { [weak self] id in self?.selectTab(id) }, + onClose: { [weak self] id in self?.closeTab(id) } + ) + } + + private func refreshChrome() { + guard let tab = activeTab else { return } + let isInternalPage = tab.documentKind.isShellularOwned + let isLoading = !isInternalPage && tab.webView.isLoading + if !isEditingAddress { + addressField.stringValue = tab.displayURL + clearAddressValidationError() + } + addressBar.updateClearButton(isEditing: isEditingAddress) + backButton.isEnabled = tab.webView.canGoBack + forwardButton.isEnabled = tab.webView.canGoForward + reloadButton.image = NSImage(systemSymbolName: isLoading ? "xmark" : "arrow.clockwise", accessibilityDescription: isLoading ? "Stop" : "Reload") + progress.isHidden = !isLoading + progress.doubleValue = isInternalPage ? 1 : tab.webView.estimatedProgress + let canRequestDeveloperTools = BrowserDeveloperToolsAvailability.canRequestVisibility( + hasRuntime: developerToolsUserScript != nil, + state: tab.developerTools + ) + developerToolsButton.isEnabled = canRequestDeveloperTools + developerToolsButton.state = tab.developerTools.isVisible ? .on : .off + developerToolsButton.isBordered = tab.developerTools.isVisible + developerToolsButton.contentTintColor = tab.developerTools.isVisible + ? palette.accent + : palette.secondaryText + if developerToolsUserScript == nil { + developerToolsButton.toolTip = BrowserDeveloperToolsContextMenu.unavailableToolTip + } else if tab.developerTools.initializationFailed { + developerToolsButton.toolTip = BrowserDeveloperToolsContextMenu.initializationFailedToolTip + } else if tab.developerTools.isReady { + developerToolsButton.toolTip = "Developer Tools (⌘⌥I or ⌘⇧I)" + } else { + developerToolsButton.toolTip = "Developer Tools are loading" + } + window?.title = tab.title.isEmpty ? "Shellular Browser" : "\(tab.title) — Shellular Browser" + updateSecurityChrome(for: tab) + syncTabStrip() + } + + private func synchronizeDeveloperTools(for tab: BrowserTab) { + guard developerToolsUserScript != nil else { return } + let command = """ + \(BrowserDeveloperToolsScript.panelPercentCommand(tab.developerTools.panelPercent)) + \(BrowserDeveloperToolsScript.visibilityCommand(isVisible: tab.developerTools.isVisible)) + """ + tab.webView.evaluateJavaScript(command) { _, error in + if let error { + NSLog( + "[Shellular Developer Tools] Failed to synchronize tab %@: %@", + tab.id, + error.localizedDescription + ) + } + } + } + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard message.name == BrowserDeveloperToolsScript.messageHandlerName, + let snapshot = BrowserDeveloperToolsSnapshot(payload: message.body), + let tab = tab(containing: message.webView) else { return } + + let wasReady = tab.developerTools.isReady + let desiredVisibility = tab.developerTools.isVisible + let desiredPanelPercent = tab.developerTools.panelPercent + tab.developerTools.apply( + snapshot, + preserveVisibility: !wasReady && snapshot.ready, + preservePanelPercent: !wasReady + ) + tab.webView.embeddedDeveloperToolsAvailable = snapshot.ready + + if !snapshot.ready { + NSLog( + "[Shellular Developer Tools] console.js failed to initialize in tab %@; embedded developer tools are disabled", + tab.id + ) + } + + if wasReady, snapshot.ready, + snapshot.panelPercent != preferredDeveloperToolsPanelPercent { + persistDeveloperToolsPanelPercent( + snapshot.panelPercent, + sourceTab: tab + ) + } else if !wasReady, snapshot.ready, + desiredVisibility != snapshot.isVisible || + desiredPanelPercent != snapshot.panelPercent { + synchronizeDeveloperTools(for: tab) + } + if tab.id == activeId { refreshChrome() } + } + + private func persistDeveloperToolsPanelPercent( + _ panelPercent: CGFloat, + sourceTab: BrowserTab + ) { + let value = BrowserDeveloperToolsMetrics.clamp(panelPercent) + preferredDeveloperToolsPanelPercent = value + developerToolsPanelSizeStore.save(value) + for tab in tabs { + tab.developerTools.setPanelPercent(value) + guard tab !== sourceTab, tab.developerTools.isReady else { continue } + tab.webView.evaluateJavaScript( + BrowserDeveloperToolsScript.panelPercentCommand(value), + completionHandler: nil + ) + } + } + + private func inspectElement(in tab: BrowserTab) { + guard developerToolsUserScript != nil, tab.developerTools.isReady else { return } + tab.developerTools.showElementHighlight() + if tab.id == activeId { refreshChrome() } + tab.webView.evaluateJavaScript( + BrowserDeveloperToolsScript.inspectElementCommand() + ) { accepted, error in + if let error { + NSLog( + "[Shellular Developer Tools] Failed to inspect an element in tab %@: %@", + tab.id, + error.localizedDescription + ) + } else if accepted as? Bool != true { + NSLog( + "[Shellular Developer Tools] Inspect Element was unavailable in tab %@", + tab.id + ) + } + } + } + + private func clearElementHighlight(in tab: BrowserTab) { + guard developerToolsUserScript != nil, tab.developerTools.isReady else { return } + tab.developerTools.clearElementHighlight() + if tab.id == activeId { refreshChrome() } + tab.webView.evaluateJavaScript( + BrowserDeveloperToolsScript.clearElementHighlightCommand() + ) { accepted, error in + if let error { + NSLog( + "[Shellular Developer Tools] Failed to clear the element highlight in tab %@: %@", + tab.id, + error.localizedDescription + ) + } else if accepted as? Bool != true { + NSLog( + "[Shellular Developer Tools] Element highlight cleanup was unavailable in tab %@", + tab.id + ) + } + } + } + + private func closeDeveloperTools(in tab: BrowserTab) { + guard developerToolsUserScript != nil, tab.developerTools.isReady else { return } + tab.developerTools.hide() + synchronizeDeveloperTools(for: tab) + if tab.id == activeId { refreshChrome() } + } + + private func updateSecurityChrome(for tab: BrowserTab) { + let symbol: String + let help: String + switch tab.securityState { + case .internalPage: + symbol = "info.circle" + help = "Shellular internal page" + case .secure: + symbol = "lock.fill" + help = "Connection is secure" + case .notSecure: + symbol = "exclamationmark.triangle" + help = "Connection is not secure" + case .unsafeCertificate: + symbol = "lock.trianglebadge.exclamationmark" + help = "Unsafe certificate exception active for this session" + } + addressBar.setSecurity(symbol: symbol, help: help, color: color(for: tab.securityState)) + } + + private func color(for state: BrowserSecurityState) -> NSColor { + switch state { + case .secure: return .systemGreen + case .notSecure, .unsafeCertificate: return .systemOrange + case .internalPage: return palette.secondaryText + } + } + + private func navigate(_ tab: BrowserTab, to url: URL) { + if url.scheme == "shellular" { + loadInternalPage(tab, name: url.host ?? "home") + return + } + tab.documentKind = .external + tab.certificateWarning = nil + tab.lastFailedURL = nil + if let exception = tab.activeTrustException, + exception.host == url.host?.lowercased(), + exception.port == (url.port ?? 443), + url.scheme?.lowercased() == "https" { + tab.securityState = .unsafeCertificate + } else { + tab.activeTrustException = nil + tab.securityState = url.scheme?.lowercased() == "https" ? .secure : .notSecure + } + tab.internalRequestId = nil + guard prepareRemoteNavigation(tab, to: url) else { return } + tab.displayURL = url.absoluteString + tab.webView.load(URLRequest(url: url)) + refreshChrome() + saveSession() + } + + private func releaseTunnel(for tab: BrowserTab) { + guard let port = tab.remoteTunnelPort else { return } + tab.remoteTunnelPort = nil + tab.remoteTunnelHost = nil + let isUsedByAnotherTab = tabs.contains { + $0 !== tab && $0.remoteTunnelPort == port + } + if !isUsedByAnotherTab { + EmbeddedProxyServer.shared.stopTunnelServer(port: port) + EmbeddedProxyServer.shared.stopServer(port: port) + } + } + + private func prepareRemoteNavigation(_ tab: BrowserTab, to url: URL) -> Bool { + guard isLoopback(url), context.transport == "remote" else { + releaseTunnel(for: tab) + if context.transport != "remote" { tab.remoteHostId = nil } + return true + } + + tab.displayURL = url.absoluteString + guard context.canProxyRemote, let hostId = context.hostId else { + tab.remoteHostId = tab.remoteHostId ?? context.hostId + showConnectionError(tab, expectedHostId: tab.remoteHostId) + return false + } + if let expected = tab.remoteHostId, expected != hostId { + showConnectionError(tab, expectedHostId: expected) + return false + } + + let port = url.port ?? (url.scheme == "https" ? 443 : 80) + let tunnelHost = normalizedTunnelHost(url.host) + if tab.remoteTunnelPort != port || tab.remoteTunnelHost != tunnelHost { + releaseTunnel(for: tab) + } + guard let mainWebView else { + showError( + tab, + title: "Browser tunnel unavailable", + message: "Shellular could not reach the app connection." + ) + return false + } + + let started: Bool + if context.canTunnelRemote { + started = EmbeddedProxyServer.shared.startTunnelServer( + port: port, + targetHost: tunnelHost, + webView: mainWebView + ) + } else if url.scheme == "http" { + started = EmbeddedProxyServer.shared.startServer(port: port, webView: mainWebView) + } else { + showError( + tab, + title: "Update the host CLI", + message: "Remote HTTPS needs the latest Shellular CLI for its end-to-end TCP tunnel. Update the connected host, reconnect, and retry." + ) + return false + } + guard started else { + showError( + tab, + title: "Port unavailable on this Mac", + message: "Shellular could not reserve local port \(port) for the remote host. Close the local service using that port and retry." + ) + return false + } + tab.remoteHostId = hostId + tab.remoteTunnelPort = port + tab.remoteTunnelHost = tunnelHost + return true + } + + private func loadHome(_ tab: BrowserTab) { + releaseTunnel(for: tab) + tab.internalRequestId = nil + tab.certificateWarning = nil + tab.activeTrustException = nil + tab.lastFailedURL = nil + tab.securityState = .internalPage + tab.documentKind = .shellular("home") + tab.webView.stopLoading() + tab.displayURL = "shellular://home" + tab.title = "Home" + loadOwnedDocument(homeDocument, in: tab) + refreshChrome() + saveSession() + } + + private func loadInternalPage(_ tab: BrowserTab, name: String) { + if name == "home" { + loadHome(tab) + return + } + guard name == "ports", let mainWebView else { + showInternalError(tab, name: name, message: "This internal page is unavailable.") + return + } + + releaseTunnel(for: tab) + let requestId = UUID() + tab.internalRequestId = requestId + tab.certificateWarning = nil + tab.activeTrustException = nil + tab.lastFailedURL = nil + tab.securityState = .internalPage + tab.documentKind = .shellular(name) + tab.webView.stopLoading() + tab.displayURL = "shellular://\(name)" + tab.title = name.capitalized + loadOwnedDocument(Self.internalLoadingHTML, in: tab) + refreshChrome() + saveSession() + + mainWebView.callAsyncJavaScript( + "return await window.__shellularPage(name)", + arguments: ["name": name], + in: nil, + in: .page + ) { [weak self, weak tab] result in + DispatchQueue.main.async { + guard let self, let tab, tab.internalRequestId == requestId else { return } + switch result { + case .success(let value): + guard let html = value as? String, !html.isEmpty else { + self.showInternalError(tab, name: name, message: "Shellular returned an empty page.") + return + } + tab.internalRequestId = nil + self.loadOwnedDocument(html, in: tab) + self.refreshChrome() + case .failure(let error): + self.showInternalError(tab, name: name, message: error.localizedDescription) + } + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self, weak tab] in + guard let self, let tab, tab.internalRequestId == requestId else { return } + self.showInternalError(tab, name: name, message: "The page took too long to load.") + } + } + + private func showInternalError(_ tab: BrowserTab, name: String, message: String) { + releaseTunnel(for: tab) + tab.internalRequestId = nil + tab.certificateWarning = nil + tab.activeTrustException = nil + tab.lastFailedURL = nil + tab.documentKind = .shellular(name) + tab.securityState = .internalPage + tab.displayURL = "shellular://\(name)" + tab.title = name.capitalized + let retryURL = "shellular://\(escapeHTML(name))" + let html = """ + + \(internalPageStyle) +

Unable to load \(escapeHTML(tab.title))

\(escapeHTML(message))

Retry + """ + loadOwnedDocument(html, in: tab) + refreshChrome() + } + + private func showConnectionError(_ tab: BrowserTab, expectedHostId: String?) { + let expected = expectedHostId ?? "the original remote host" + showError(tab, title: "Remote host unavailable", message: "Reconnect to \(expected) before reloading this localhost tab. Shellular will not open the Mac's localhost in its place.") + } + + private func showError(_ tab: BrowserTab, title: String, message: String) { + tab.internalRequestId = nil + tab.documentKind = .browserUI + if let url = URL(string: tab.displayURL), + ["http", "https"].contains(url.scheme?.lowercased() ?? "") { + tab.lastFailedURL = url + } + let html = """ + + \(internalPageStyle) +
!

\(escapeHTML(title))

+

\(escapeHTML(message))

+

\(escapeHTML(tab.displayURL))

+
+ """ + loadOwnedDocument(html, in: tab) + refreshChrome() + } + + private func loadOwnedDocument(_ html: String, in tab: BrowserTab) { + BrowserOwnedDocument(displayURL: tab.displayURL, html: html).load(in: tab.webView) + } + + private func saveSession() { + let saved = tabs.compactMap { tab -> SavedBrowserTab? in + guard !tab.ephemeral, !tab.displayURL.isEmpty else { return nil } + return SavedBrowserTab(id: tab.id, url: tab.displayURL, title: tab.title, remoteHostId: tab.remoteHostId) + } + guard let data = try? JSONEncoder().encode(SavedBrowserSession(tabs: saved, activeId: activeId)) else { return } + UserDefaults.standard.set(data, forKey: Self.sessionKey) + } + + private func restoreSession(loadTabs: Bool = true) { + guard loadTabs, + let data = UserDefaults.standard.data(forKey: Self.sessionKey), + let saved = try? JSONDecoder().decode(SavedBrowserSession.self, from: data) else { return } + for item in saved.tabs.prefix(30) { + guard ["shellular://home", "shellular://ports"].contains(item.url) || BrowserAddressResolver.isRestorable(item.url) else { continue } + let tab = makeTab(id: item.id, url: item.url, title: item.title, remoteHostId: item.remoteHostId) + tab.needsRestoreLoad = true + tabs.append(tab) + } + let selected = tabs.first(where: { $0.id == saved.activeId }) ?? tabs.first + if let selected { selectTab(selected.id) } + } + + private func reopenClosedTab() { + guard let saved = closedTabs.popLast(), let url = URL(string: saved.url) else { return } + let tab = makeTab(url: saved.url, title: saved.title, remoteHostId: saved.remoteHostId) + tabs.append(tab) + selectTab(tab.id) + navigate(tab, to: url) + } + + func handleKeyEquivalent(_ event: NSEvent) -> Bool { + guard event.type == .keyDown else { return false } + if BrowserDeveloperToolsShortcut.matches(event) { + developerToolsAction() + return true + } + let command = event.modifierFlags.contains(.command) + let shift = event.modifierFlags.contains(.shift) + let control = event.modifierFlags.contains(.control) + let key = event.charactersIgnoringModifiers?.lowercased() ?? "" + if command && shift && key == "t" { reopenClosedTab(); return true } + if command && shift && key == "a" { searchTabsAction(); return true } + if command && key == "t" { newHomeTab(); return true } + if command && key == "w" { if let activeId { closeTab(activeId) }; return true } + if command && key == "l" { focusAddressField(); return true } + if command && key == "r" { reloadAction(); return true } + if command, let number = Int(key), (1...9).contains(number), !tabs.isEmpty { + selectTab(tabs[min(number - 1, tabs.count - 1)].id); return true + } + if control && key == "\t" { + selectRelativeTab(shift ? -1 : 1); return true + } + return false + } + + private func handleSidebarKeyEquivalent(_ event: NSEvent) -> Bool { + handleKeyEquivalent(event) + } + + private func focusAddressField() { + rootView.window?.makeFirstResponder(addressField) + addressField.selectText(nil) + } + + private func selectRelativeTab(_ delta: Int) { + guard let activeId, let index = tabs.firstIndex(where: { $0.id == activeId }), !tabs.isEmpty else { return } + selectTab(tabs[(index + delta + tabs.count) % tabs.count].id) + } + + @objc private func newTabAction() { newHomeTab() } + @objc private func backAction() { activeTab?.webView.goBack() } + @objc private func forwardAction() { activeTab?.webView.goForward() } + @objc private func reloadAction() { + guard let tab = activeTab else { return } + if !tab.displayURL.hasPrefix("shellular://"), tab.webView.isLoading { tab.webView.stopLoading() } + else if let url = URL(string: tab.displayURL), url.scheme == "shellular" { + loadInternalPage(tab, name: url.host ?? "home") + } + else if let url = URL(string: tab.displayURL) { navigate(tab, to: url) } + } + @objc private func homeAction() { if let tab = activeTab { loadHome(tab) } } + @objc private func developerToolsAction() { + guard let tab = activeTab, + BrowserDeveloperToolsAvailability.canRequestVisibility( + hasRuntime: developerToolsUserScript != nil, + state: tab.developerTools + ) else { + NSSound.beep() + return + } + tab.developerTools.toggle() + synchronizeDeveloperTools(for: tab) + refreshChrome() + } + @objc private func moreAction() { + guard let tab = activeTab else { return } + let menu = NSMenu(title: "Browser Options") + + let developerToolsItem = NSMenuItem( + title: tab.developerTools.isVisible + ? BrowserDeveloperToolsContextMenu.closeDeveloperToolsTitle + : "Developer Tools", + action: #selector(developerToolsAction), + keyEquivalent: "" + ) + developerToolsItem.target = self + developerToolsItem.isEnabled = BrowserDeveloperToolsAvailability.canRequestVisibility( + hasRuntime: developerToolsUserScript != nil, + state: tab.developerTools + ) + developerToolsItem.state = tab.developerTools.isVisible ? .on : .off + menu.addItem(developerToolsItem) + menu.addItem(.separator()) + + let viewportItem = NSMenuItem(title: "Viewport", action: nil, keyEquivalent: "") + let viewportMenu = NSMenu(title: "Viewport") + addViewportItem("Fit", tag: 0, mode: .fit, tab: tab, to: viewportMenu) + addViewportItem("Phone (390 px)", tag: 390, mode: .phone, tab: tab, to: viewportMenu) + addViewportItem("Tablet (768 px)", tag: 768, mode: .tablet, tab: tab, to: viewportMenu) + let customItem = NSMenuItem( + title: "Custom…", + action: #selector(customViewportAction), + keyEquivalent: "" + ) + customItem.target = self + if case .fixed(let width) = tab.viewportHost.mode, + width != 390, width != 768 { + customItem.state = .on + customItem.title = "Custom… (\(Int(width)) px)" + } + viewportMenu.addItem(customItem) + menu.addItem(viewportItem) + menu.setSubmenu(viewportMenu, for: viewportItem) + + let screenshotItem = NSMenuItem( + title: "Capture Screenshot…", + action: #selector(captureScreenshotAction), + keyEquivalent: "" + ) + screenshotItem.target = self + menu.addItem(screenshotItem) + menu.popUp( + positioning: nil, + at: NSPoint(x: 0, y: moreButton.bounds.height + 4), + in: moreButton + ) + } + private func addViewportItem( + _ title: String, + tag: Int, + mode: BrowserViewportMode, + tab: BrowserTab, + to menu: NSMenu + ) { + let item = NSMenuItem( + title: title, + action: #selector(viewportPresetAction(_:)), + keyEquivalent: "" + ) + item.target = self + item.tag = tag + item.state = tab.viewportHost.mode == mode ? .on : .off + menu.addItem(item) + } + @objc private func viewportPresetAction(_ sender: NSMenuItem) { + guard let tab = activeTab else { return } + tab.viewportHost.mode = sender.tag == 0 ? .fit : .fixed(CGFloat(sender.tag)) + tab.viewportHost.needsLayout = true + tab.viewportHost.layoutSubtreeIfNeeded() + } + @objc private func customViewportAction() { + guard let tab = activeTab, let presentationWindow else { return } + let alert = NSAlert() + alert.messageText = "Custom viewport width" + alert.informativeText = "Enter a CSS-pixel width from 320 to 1920." + alert.addButton(withTitle: "Apply") + alert.addButton(withTitle: "Cancel") + let currentWidth: CGFloat + if case .fixed(let width) = tab.viewportHost.mode { + currentWidth = width + } else { + currentWidth = 390 + } + let field = NSTextField(string: String(Int(currentWidth))) + field.frame.size.width = 220 + field.placeholderString = "320–1920" + alert.accessoryView = field + alert.beginSheetModal(for: presentationWindow) { [weak self, weak tab] response in + guard response == .alertFirstButtonReturn, + let self, + let tab, + self.tabs.contains(where: { $0 === tab }), + let width = Double(field.stringValue), + (Double(BrowserViewportMode.minimumCustomWidth)...Double(BrowserViewportMode.maximumCustomWidth)).contains(width) else { + if response == .alertFirstButtonReturn { NSSound.beep() } + return + } + tab.viewportHost.mode = .fixed(CGFloat(width)) + tab.viewportHost.layoutSubtreeIfNeeded() + } + } + @objc private func captureScreenshotAction() { + guard let tab = activeTab else { return } + let snapshotRect = BrowserScreenshotMetrics.visiblePageRect( + in: tab.webView.bounds, + developerToolsVisible: tab.developerTools.isVisible, + panelPercent: tab.developerTools.panelPercent + ) + guard snapshotRect.width > 0, snapshotRect.height > 0 else { + presentJavaScriptAlert( + message: "The visible page area is empty and could not be captured.", + buttons: ["OK"] + ) { _ in } + return + } + let configuration = WKSnapshotConfiguration() + configuration.rect = snapshotRect + tab.webView.takeSnapshot(with: configuration) { [weak self, weak tab] image, error in + DispatchQueue.main.async { + guard let self, + let tab, + self.tabs.contains(where: { $0 === tab }) else { return } + guard let image, + let data = BrowserScreenshotEncoder.pngData(from: image) else { + self.presentJavaScriptAlert( + message: error?.localizedDescription ?? "The page screenshot could not be encoded as PNG.", + buttons: ["OK"] + ) { _ in } + return + } + self.presentScreenshotSavePanel(data: data, tab: tab) + } + } + } + private func presentScreenshotSavePanel(data: Data, tab: BrowserTab) { + guard let presentationWindow else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [.png] + let baseName = tab.title + .components(separatedBy: CharacterSet.alphanumerics.union(.whitespaces).inverted) + .joined() + .trimmingCharacters(in: .whitespacesAndNewlines) + panel.nameFieldStringValue = "\(baseName.isEmpty ? "Shellular Screenshot" : baseName).png" + panel.beginSheetModal(for: presentationWindow) { [weak self] response in + guard let url = BrowserScreenshotSaveDecision.destination( + for: response, + selectedURL: panel.url + ) else { return } + do { + try data.write(to: url, options: .atomic) + } catch { + self?.presentJavaScriptAlert( + message: "The screenshot could not be saved: \(error.localizedDescription)", + buttons: ["OK"] + ) { _ in } + } + } + } + @objc private func presentationAction() { + if isPresentedInWindow { onRequestDock?(true) } + else { onRequestPopOut?() } + } + @objc private func closeSidebarAction() { onRequestCloseSidebar?() } + @objc private func clearAddressAction() { + addressField.stringValue = "" + clearAddressValidationError() + addressBar.updateClearButton(isEditing: true) + rootView.window?.makeFirstResponder(addressField) + } + @objc private func securityAction() { + guard let tab = activeTab else { return } + let message: String + switch tab.securityState { + case .secure: + message = "The page is using HTTPS and its certificate is trusted." + case .notSecure: + message = "This page is not protected by HTTPS." + case .unsafeCertificate: + message = "You explicitly allowed this certificate for the current Shellular browser session. The exception will be forgotten when this browser session ends or the certificate changes." + case .internalPage: + message = "This is a local Shellular browser page." + } + presentJavaScriptAlert(message: message, buttons: ["OK"]) { _ in } + } + @objc private func addressAction() { + guard let tab = activeTab else { return } + guard let url = BrowserAddressResolver.resolve(addressField.stringValue) else { + addressValidationError = true + addressBar.setValidationError(true) + addressField.toolTip = "Enter a web or shellular:// address. javascript:, data:, and file: URLs are not allowed." + NSSound.beep() + return + } + isEditingAddress = false + clearAddressValidationError() + rootView.window?.makeFirstResponder(nil) + navigate(tab, to: url) + } + @objc private func searchTabsAction() { + let menu = NSMenu(title: "Open Tabs") + for tab in tabs { + let item = NSMenuItem(title: tab.title.isEmpty ? tab.displayURL : tab.title, action: #selector(tabMenuAction(_:)), keyEquivalent: "") + item.representedObject = tab.id + item.target = self + item.state = tab.id == activeId ? .on : .off + menu.addItem(item) + } + menu.popUp(positioning: nil, at: NSPoint(x: 0, y: tabSearchButton.bounds.height + 4), in: tabSearchButton) + } + @objc private func tabMenuAction(_ sender: NSMenuItem) { + if let id = sender.representedObject as? String { selectTab(id) } + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { addressAction(); return true } + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + isEditingAddress = false + addressBar.setFocused(false) + addressBar.updateClearButton(isEditing: false) + clearAddressValidationError() + refreshChrome() + rootView.window?.makeFirstResponder(nil) + return true + } + return false + } + + func controlTextDidBeginEditing(_ obj: Notification) { + guard obj.object as? NSTextField === addressField else { return } + isEditingAddress = true + addressBar.setFocused(true) + addressBar.updateClearButton(isEditing: true) + clearAddressValidationError() + } + + func controlTextDidChange(_ obj: Notification) { + guard obj.object as? NSTextField === addressField else { return } + addressBar.updateClearButton(isEditing: true) + clearAddressValidationError() + } + + func controlTextDidEndEditing(_ obj: Notification) { + guard obj.object as? NSTextField === addressField else { return } + isEditingAddress = false + addressBar.setFocused(false) + addressBar.updateClearButton(isEditing: false) + refreshChrome() + } + + private func clearAddressValidationError() { + guard addressValidationError || addressField.toolTip != nil else { return } + addressValidationError = false + addressBar.setValidationError(false) + addressField.toolTip = nil + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let tab = tabs.first(where: { $0.webView === webView }), let url = navigationAction.request.url else { + return decisionHandler(.cancel) + } + if url.scheme == "shellular" { + if handleBrowserAction(tab, url: url) { + decisionHandler(.cancel) + return + } + loadInternalPage(tab, name: url.host ?? "home") + decisionHandler(.cancel) + return + } + if BrowserOwnedDocument.isPhysicalHTMLNavigation(url) { + decisionHandler(.allow) + return + } + if navigationAction.targetFrame == nil { + let newTab = makeTab(url: url.absoluteString, title: url.host ?? "New Tab") + tabs.append(newTab) + selectTab(newTab.id) + navigate(newTab, to: url) + decisionHandler(.cancel) + return } + if navigationAction.targetFrame?.isMainFrame != true { + decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow) + return + } + if !prepareRemoteNavigation(tab, to: url) { + decisionHandler(.cancel) + return + } + if ["http", "https"].contains(url.scheme?.lowercased() ?? "") { + tab.documentKind = .external + tab.securityState = url.scheme?.lowercased() == "https" ? .secure : .notSecure + } + tab.displayURL = url.absoluteString + decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow) + } + + private func handleBrowserAction(_ tab: BrowserTab, url: URL) -> Bool { + switch url.host { + case "certificate": + let action = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if action == "proceed", + let token = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "token" })?.value, + let warning = tab.certificateWarning, + warning.token == token, + warningTokens.consume(token) { + tab.certificateWarning = nil + trustExceptions.approve(warning.exception) + tab.activeTrustException = warning.exception + tab.securityState = .unsafeCertificate + navigate(tab, to: warning.url) + } else if action == "back" { + if let token = tab.certificateWarning?.token { + _ = warningTokens.consume(token) + } + tab.certificateWarning = nil + tab.lastFailedURL = nil + if tab.webView.canGoBack { tab.webView.goBack() } + else { loadHome(tab) } + } + return true + case "error": + let action = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + switch action { + case "back": + tab.lastFailedURL = nil + if tab.webView.canGoBack { tab.webView.goBack() } + else { loadHome(tab) } + case "retry": + if let failedURL = tab.lastFailedURL { navigate(tab, to: failedURL) } + case "copy": + let value = tab.lastFailedURL?.absoluteString ?? tab.displayURL + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + case "external": + if let failedURL = tab.lastFailedURL { NSWorkspace.shared.open(failedURL) } + default: + break + } + return true + default: + return false + } + } + + func webView( + _ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping ( + URLSession.AuthChallengeDisposition, + URLCredential? + ) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == + NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust, + let tab = tabs.first(where: { $0.webView === webView }), + let pageURL = URL(string: tab.displayURL), + challengeMatchesMainPage(challenge.protectionSpace, pageURL: pageURL) else { + completionHandler(.performDefaultHandling, nil) + return + } + + let host = challenge.protectionSpace.host + let port = challenge.protectionSpace.port > 0 + ? challenge.protectionSpace.port + : 443 + BrowserTrustEvaluationScheduler.schedule(on: trustEvaluationQueue) { + [weak self, weak tab] queue in + dispatchPrecondition(condition: .onQueue(queue)) + let status = SecTrustEvaluateAsyncWithError( + trust, + queue + ) { [weak self, weak tab] evaluatedTrust, trusted, evaluationError in + dispatchPrecondition(condition: .onQueue(queue)) + let metadata = trusted ? nil : Self.certificateMetadata( + for: evaluatedTrust, + host: host, + port: port, + error: evaluationError + ) + DispatchQueue.main.async { + guard let self, let tab, self.tabs.contains(where: { $0 === tab }) else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + if trusted { + tab.activeTrustException = nil + tab.securityState = .secure + self.refreshChrome() + completionHandler(.performDefaultHandling, nil) + return + } + guard let metadata else { + completionHandler(.performDefaultHandling, nil) + return + } + if self.trustExceptions.contains(metadata.exception) { + tab.activeTrustException = metadata.exception + tab.securityState = .unsafeCertificate + self.refreshChrome() + completionHandler(.useCredential, URLCredential(trust: trust)) + return + } + + completionHandler(.cancelAuthenticationChallenge, nil) + tab.activeTrustException = nil + self.showCertificateWarning( + tab, + url: pageURL, + metadata: metadata + ) + } + } + if status != errSecSuccess { + DispatchQueue.main.async { + guard let self, let tab, self.tabs.contains(where: { $0 === tab }) else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + completionHandler(.performDefaultHandling, nil) + } + } + } + } + + private func challengeMatchesMainPage( + _ protectionSpace: URLProtectionSpace, + pageURL: URL + ) -> Bool { + guard pageURL.scheme?.lowercased() == "https", + pageURL.host?.lowercased() == protectionSpace.host.lowercased() else { + return false + } + let challengePort = protectionSpace.port > 0 ? protectionSpace.port : 443 + return (pageURL.port ?? 443) == challengePort + } + + private static func certificateMetadata( + for trust: SecTrust, + host: String, + port: Int, + error: CFError? + ) -> BrowserCertificateMetadata? { + guard let certificate = leafCertificate(in: trust) else { return nil } + let certificateData = SecCertificateCopyData(certificate) as Data + let fingerprint = SHA256.hash(data: certificateData) + .map { String(format: "%02X", $0) } + .joined(separator: ":") + let exception = BrowserTrustException( + host: host.lowercased(), + port: port, + certificateFingerprint: fingerprint + ) + let subject = SecCertificateCopySubjectSummary(certificate) as String? ?? exception.host + let details = error.map { CFErrorCopyDescription($0) as String } ?? + "The certificate could not be verified." + return BrowserCertificateMetadata( + exception: exception, + subject: subject, + details: details + ) + } + + private func showCertificateWarning( + _ tab: BrowserTab, + url: URL, + metadata: BrowserCertificateMetadata + ) { + let exception = metadata.exception + if let oldToken = tab.certificateWarning?.token { + _ = warningTokens.consume(oldToken) + } + let token = warningTokens.issue() + tab.certificateWarning = PendingCertificateWarning( + token: token, + exception: exception, + url: url, + details: metadata.details + ) + tab.lastFailedURL = url + tab.securityState = .unsafeCertificate + tab.documentKind = .browserUI + tab.title = "Your connection is not private" + tab.displayURL = url.absoluteString + let encodedToken = token.addingPercentEncoding( + withAllowedCharacters: .urlQueryAllowed + ) ?? token + let html = """ + + \(internalPageStyle) +
!
+

Your connection is not private

+

Attackers might be trying to steal information from + \(escapeHTML(exception.host)). Shellular did not continue + because the certificate is not trusted.

+
Certificate details +
Site
\(escapeHTML(metadata.subject))
+
Address
\(escapeHTML(url.absoluteString))
+
Fingerprint (SHA-256)
\(escapeHTML(exception.certificateFingerprint))
+
Reason
\(escapeHTML(metadata.details))
+
+ """ + loadOwnedDocument(html, in: tab) + refreshChrome() + } + + private static func leafCertificate(in trust: SecTrust) -> SecCertificate? { + (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + decisionHandler(navigationResponse.canShowMIMEType ? .allow : .download) + } + + func webView( + _ webView: WKWebView, + didStartProvisionalNavigation navigation: WKNavigation! + ) { + guard let tab = tabs.first(where: { $0.webView === webView }) else { return } + tab.developerTools.markRuntimeLoading() + tab.webView.embeddedDeveloperToolsAvailable = false + if tab.id == activeId { refreshChrome() } + } + + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + guard let tab = tabs.first(where: { $0.webView === webView }) else { return } + tab.developerTools.clearElementHighlight() + synchronizeDeveloperTools(for: tab) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let tab = tabs.first(where: { $0.webView === webView }) else { return } + synchronizeDeveloperTools(for: tab) + switch tab.documentKind { + case .external: + tab.title = webView.title ?? tab.title + case .shellular(let name): + tab.title = name.capitalized + case .browserUI: + break + } + if tab.documentKind.isShellularOwned { applyTheme(to: tab) } + if tab.documentKind == .external, + tab.securityState != .unsafeCertificate, + URL(string: tab.displayURL)?.scheme?.lowercased() == "https" { + tab.securityState = .secure + } + refreshChrome() + saveSession() + guard tab.certificateWarning == nil, tab.lastFailedURL == nil else { return } + guard !tab.displayURL.hasPrefix("shellular://") else { return } + guard let mainWebView else { return } + let entry: [String: Any] = ["url": tab.displayURL, "title": tab.title, "favicon": ""] + mainWebView.callAsyncJavaScript( + "window.__shellularHistory && window.__shellularHistory(entry); return await window.__shellularPage('home')", + arguments: ["entry": entry], + in: nil, + in: .page + ) { [weak self] result in + guard case .success(let value) = result, let html = value as? String, !html.isEmpty else { return } + DispatchQueue.main.async { self?.homeDocument = html } + } + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + handleNavigationFailure(in: webView, error: error) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + handleNavigationFailure(in: webView, error: error) + } + + private func handleNavigationFailure(in webView: WKWebView, error: Error) { + guard let tab = tabs.first(where: { $0.webView === webView }), + (error as NSError).code != NSURLErrorCancelled, + tab.documentKind != .browserUI else { return } + showError(tab, title: "Page unavailable", message: error.localizedDescription) + } + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + guard let tab = tabs.first(where: { $0.webView === webView }) else { return } + showError(tab, title: "Page stopped responding", message: "The WebKit content process ended. Reload the tab to continue.") + } + + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + let url = navigationAction.request.url?.absoluteString ?? "" + let tab = makeTab(url: url, title: navigationAction.request.url?.host ?? "New Tab", configuration: configuration) + tabs.append(tab) + selectTab(tab.id) + return tab.webView + } + + func webViewDidClose(_ webView: WKWebView) { + if let tab = tabs.first(where: { $0.webView === webView }) { closeTab(tab.id) } + } + + func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = parameters.allowsMultipleSelection + panel.canChooseFiles = true + panel.canChooseDirectories = parameters.allowsDirectories + guard let presentationWindow else { + completionHandler(nil) + return + } + panel.beginSheetModal(for: presentationWindow) { + response in completionHandler(response == .OK ? panel.urls : nil) + } + } + + func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { + presentJavaScriptAlert(message: message, buttons: ["OK"]) { _ in completionHandler() } + } + + func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { + presentJavaScriptAlert(message: message, buttons: ["OK", "Cancel"]) { completionHandler($0 == .alertFirstButtonReturn) } + } + + func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) { + let field = NSTextField(string: defaultText ?? "") + field.frame.size.width = 320 + presentJavaScriptAlert(message: prompt, buttons: ["OK", "Cancel"], accessory: field) { + completionHandler($0 == .alertFirstButtonReturn ? field.stringValue : nil) + } + } + + private func presentJavaScriptAlert(message: String, buttons: [String], accessory: NSView? = nil, completion: @escaping (NSApplication.ModalResponse) -> Void) { + let alert = NSAlert() + alert.messageText = activeTab?.title ?? "Shellular Browser" + alert.informativeText = message + buttons.forEach { alert.addButton(withTitle: $0) } + alert.accessoryView = accessory + if let presentationWindow { + alert.beginSheetModal(for: presentationWindow, completionHandler: completion) + } + else { completion(alert.runModal()) } + } + + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { download.delegate = self } + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { download.delegate = self } + + func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { + let panel = NSSavePanel() + panel.nameFieldStringValue = suggestedFilename + guard let presentationWindow else { + completionHandler(nil) + return + } + panel.beginSheetModal(for: presentationWindow) { + result in completionHandler(result == .OK ? panel.url : nil) + } + } + func downloadDidFinish(_ download: WKDownload) {} + func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + presentJavaScriptAlert(message: error.localizedDescription, buttons: ["OK"]) { _ in } + } + + func windowWillClose(_ notification: Notification) { + saveSession() + EmbeddedProxyServer.shared.cancelAllPending() + EmbeddedProxyServer.shared.stopAll() + onClose?() + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + if isPresentedInWindow { + onRequestDock?(false) + return false + } + return true + } + + private var internalPageStyle: String { + """ + + """ + } + + private static let fallbackHomeHTML = """ + + +

Shellular

Use the address bar to search or open a development URL.

+ """ + + private static let internalLoadingHTML = """ + + +

Loading…

+ """ +} + +private final class ShellularAssetSchemeHandler: NSObject, WKURLSchemeHandler { + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url, url.host == "assets" else { + urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist)) + return + } + let filename = url.lastPathComponent + let bundleURL = Bundle.main.bundleURL.appendingPathComponent("bundle/\(filename)") + guard let data = try? Data(contentsOf: bundleURL) else { + urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist)) + return + } + let mime = filename.hasSuffix(".css") ? "text/css" : filename.hasSuffix(".ttf") ? "font/ttf" : "application/octet-stream" + let response = URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: nil) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} +} + +private func isLoopback(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1" || host.hasSuffix(".localhost") +} + +private func normalizedTunnelHost(_ host: String?) -> String { + switch host?.lowercased() { + case "::1": return "::1" + case "0.0.0.0": return "0.0.0.0" + case "127.0.0.1": return "127.0.0.1" + default: return "localhost" } - func presentationAnchor(for session:ASWebAuthenticationSession)->ASPresentationAnchor{viewController?.view.window ?? NSApp.keyWindow!} } -final class BrowserWindowController:NSWindowController,WKNavigationDelegate,NSWindowDelegate{ - private let web=WKWebView() - var onClose:(()->Void)? - init(){let w=NSWindow(contentRect:NSRect(x:0,y:0,width:1000,height:700),styleMask:[.titled,.closable,.miniaturizable,.resizable],backing:.buffered,defer:false);super.init(window:w);w.title="Shellular Browser";w.contentView=web;w.center();w.delegate=self;web.navigationDelegate=self} - required init?(coder:NSCoder){fatalError()} - func load(_ url:URL){web.load(URLRequest(url:url))} - func loadHTML(_ html:String){web.loadHTMLString(html,baseURL:nil)} - func windowWillClose(_ notification:Notification){onClose?()} +private func escapeHTML(_ value: String) -> String { + value.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) } diff --git a/platforms/macos/shellular/BrowserSupport.swift b/platforms/macos/shellular/BrowserSupport.swift new file mode 100644 index 0000000..5e6235c --- /dev/null +++ b/platforms/macos/shellular/BrowserSupport.swift @@ -0,0 +1,1671 @@ +import AppKit +import Foundation +import QuartzCore +import WebKit + +enum BrowserViewAttachment { + @discardableResult + static func attach(_ content: NSView, to host: NSView) -> Bool { + guard host.bounds.width > 0 else { return false } + + if content.superview !== host { + content.removeFromSuperview() + content.translatesAutoresizingMaskIntoConstraints = true + content.autoresizingMask = [.width, .height] + content.frame = host.bounds + host.addSubview(content) + } else if content.frame != host.bounds { + content.frame = host.bounds + } + return true + } + + static func synchronize(_ content: NSView?, with host: NSView) { + guard let content, content.superview === host else { return } + content.translatesAutoresizingMaskIntoConstraints = true + content.autoresizingMask = [.width, .height] + if content.frame != host.bounds { + content.frame = host.bounds + } + } +} + +struct BrowserOwnedDocument { + let displayURL: String + let html: String + + // The address shown to the user is controller state. Giving WebKit a + // shellular:// base URL turns generated HTML into a custom-scheme main-frame + // navigation and causes the route delegate to cancel the document itself. + var baseURL: URL? { nil } + + @discardableResult + func load(in webView: WKWebView) -> WKNavigation? { + webView.loadHTMLString(html, baseURL: baseURL) + } + + static func isPhysicalHTMLNavigation(_ url: URL) -> Bool { + url.absoluteString == "about:blank" + } +} + +enum BrowserDeveloperToolsMetrics { + static let version = 1 + static let minimumPanelPercent: CGFloat = 25 + static let maximumPanelPercent: CGFloat = 70 + static let defaultPanelPercent: CGFloat = 45 + static let panelPercentDefaultsKey = "shellular.browser.developer-tools.panel-percent.v1" + + static func clamp(_ value: CGFloat) -> CGFloat { + guard value.isFinite else { return defaultPanelPercent } + return min(maximumPanelPercent, max(minimumPanelPercent, value)) + } +} + +struct BrowserDeveloperToolsSnapshot: Equatable { + let ready: Bool + let isVisible: Bool + let panelPercent: CGFloat + let isElementHighlighted: Bool + + init?(payload: Any) { + let allowedKeys = Set(["version", "ready", "visible", "panelPercent", "highlighted"]) + guard let value = payload as? [String: Any], + Set(value.keys) == allowedKeys, + let version = value["version"] as? NSNumber, + CFGetTypeID(version) != CFBooleanGetTypeID(), + version.doubleValue == Double(BrowserDeveloperToolsMetrics.version), + let ready = value["ready"] as? Bool, + let isVisible = value["visible"] as? Bool, + let panelPercent = value["panelPercent"] as? NSNumber, + CFGetTypeID(panelPercent) != CFBooleanGetTypeID(), + let isElementHighlighted = value["highlighted"] as? Bool else { + return nil + } + self.ready = ready + self.isVisible = isVisible + self.panelPercent = BrowserDeveloperToolsMetrics.clamp( + CGFloat(panelPercent.doubleValue) + ) + self.isElementHighlighted = isElementHighlighted + } +} + +struct BrowserDeveloperToolsState: Equatable { + private(set) var isReady = false + private(set) var initializationFailed = false + private(set) var isVisible = false + private(set) var isElementHighlighted = false + private(set) var panelPercent: CGFloat + + init(panelPercent: CGFloat = BrowserDeveloperToolsMetrics.defaultPanelPercent) { + self.panelPercent = BrowserDeveloperToolsMetrics.clamp(panelPercent) + } + + mutating func show() { + isVisible = true + } + + mutating func showElementHighlight() { + isVisible = true + isElementHighlighted = true + } + + mutating func clearElementHighlight() { + isElementHighlighted = false + } + + mutating func hide() { + isVisible = false + isElementHighlighted = false + } + + mutating func markRuntimeLoading() { + isReady = false + initializationFailed = false + isElementHighlighted = false + } + + mutating func setPanelPercent(_ value: CGFloat) { + panelPercent = BrowserDeveloperToolsMetrics.clamp(value) + } + + mutating func apply( + _ snapshot: BrowserDeveloperToolsSnapshot, + preserveVisibility: Bool = false, + preservePanelPercent: Bool = false + ) { + isReady = snapshot.ready + initializationFailed = !snapshot.ready + if !preservePanelPercent { + panelPercent = snapshot.panelPercent + } + if !preserveVisibility { + isVisible = snapshot.isVisible + } + isElementHighlighted = snapshot.isElementHighlighted && isVisible + } + + @discardableResult + mutating func toggle() -> Bool { + if isVisible { + hide() + } else { + show() + } + return isVisible + } +} + +enum BrowserDeveloperToolsAvailability { + static func canRequestVisibility( + hasRuntime: Bool, + state: BrowserDeveloperToolsState + ) -> Bool { + hasRuntime && !state.initializationFailed + } +} + +struct BrowserDeveloperToolsPanelSizeStore { + private let defaults: UserDefaults + private let key: String + + init( + defaults: UserDefaults = .standard, + key: String = BrowserDeveloperToolsMetrics.panelPercentDefaultsKey + ) { + self.defaults = defaults + self.key = key + } + + func load() -> CGFloat { + guard defaults.object(forKey: key) != nil else { + return BrowserDeveloperToolsMetrics.defaultPanelPercent + } + return BrowserDeveloperToolsMetrics.clamp(CGFloat(defaults.double(forKey: key))) + } + + func save(_ value: CGFloat) { + defaults.set(Double(BrowserDeveloperToolsMetrics.clamp(value)), forKey: key) + } +} + +final class BrowserWeakScriptMessageHandler: NSObject, WKScriptMessageHandler { + weak var delegate: WKScriptMessageHandler? + + init(delegate: WKScriptMessageHandler? = nil) { + self.delegate = delegate + } + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + delegate?.userContentController(userContentController, didReceive: message) + } +} + +enum BrowserDeveloperToolsShortcut { + static func matches(_ event: NSEvent) -> Bool { + let usesOption = event.modifierFlags.contains(.option) + let usesShift = event.modifierFlags.contains(.shift) + guard event.type == .keyDown, + event.modifierFlags.contains(.command), + usesOption != usesShift, + !event.modifierFlags.contains(.control) else { return false } + return event.charactersIgnoringModifiers?.lowercased() == "i" + } +} + +enum BrowserDeveloperToolsScript { + static let messageHandlerName = "shellularDeveloperTools" + static let visibilityFunction = "__shellularSetDeveloperToolsVisible" + static let inspectElementFunction = "__shellularInspectContextElement" + static let clearElementHighlightFunction = "__shellularClearElementHighlight" + + static func loadRuntime( + resourceURL: URL? = Bundle.main.resourceURL, + fileManager: FileManager = .default + ) -> String? { + guard let resourceURL else { return nil } + for relativePath in ["bundle/console.js", "console.js"] { + let url = resourceURL.appendingPathComponent(relativePath) + guard fileManager.fileExists(atPath: url.path), + let source = try? String(contentsOf: url, encoding: .utf8), + !source.isEmpty else { continue } + return source + } + return nil + } + + static func makeUserScript( + erudaSource: String, + panelPercent: CGFloat = BrowserDeveloperToolsMetrics.defaultPanelPercent + ) -> WKUserScript { + WKUserScript( + source: makeSource(erudaSource: erudaSource, panelPercent: panelPercent), + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + } + + static func visibilityCommand(isVisible: Bool) -> String { + let value = isVisible ? "true" : "false" + return """ + (function(){ + var tools = window.__shellularDeveloperTools; + if (tools && typeof tools.\(isVisible ? "show" : "hide") === "function") { + return tools.\(isVisible ? "show" : "hide")(); + } + var legacy = window.\(visibilityFunction); + return typeof legacy === "function" ? legacy(\(value)) : false; + })(); + """ + } + + static func panelPercentCommand(_ panelPercent: CGFloat) -> String { + let value = BrowserDeveloperToolsMetrics.clamp(panelPercent) + return """ + (function(){ + var tools = window.__shellularDeveloperTools; + if (!tools || typeof tools.setPanelPercent !== "function") return false; + tools.setPanelPercent(\(Double(value))); + return true; + })(); + """ + } + + static func inspectElementCommand() -> String { + """ + (function(){ + var tools = window.__shellularDeveloperTools; + if (tools && typeof tools.inspectElement === "function") { + return tools.inspectElement(); + } + var legacy = window.\(inspectElementFunction); + return typeof legacy === "function" ? legacy() : false; + })(); + """ + } + + static func clearElementHighlightCommand() -> String { + """ + (function(){ + var tools = window.__shellularDeveloperTools; + if (tools && typeof tools.clearHighlight === "function") { + return tools.clearHighlight(); + } + var legacy = window.\(clearElementHighlightFunction); + return typeof legacy === "function" ? legacy() : false; + })(); + """ + } + + private static func makeSource(erudaSource: String, panelPercent: CGFloat) -> String { + let clampedPanelPercent = BrowserDeveloperToolsMetrics.clamp(panelPercent) + return """ + (function(){ + "use strict"; + function installShellularDeveloperTools(){ + if (window.__shellularDeveloperTools && + window.__shellularDeveloperTools.version === \(BrowserDeveloperToolsMetrics.version)) { + window.__shellularDeveloperTools.setPanelPercent(\(Double(clampedPanelPercent))); + return; + } + try { + \(erudaSource) + if (typeof window.__shellularInstallDeveloperTools !== "function") { + throw new Error("console.js did not install the Shellular developer tools adapter"); + } + window.__shellularInstallDeveloperTools({ + panelPercent: \(Double(clampedPanelPercent)), + messageHandlerName: "\(messageHandlerName)" + }); + } catch (error) { + console.error("[Shellular Developer Tools]", error); + try { + window.webkit.messageHandlers.\(messageHandlerName).postMessage({ + version: \(BrowserDeveloperToolsMetrics.version), + ready: false, + visible: false, + panelPercent: \(Double(clampedPanelPercent)), + highlighted: false + }); + } catch (_) {} + } + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function(){ + window.setTimeout(installShellularDeveloperTools, 0); + }, { once: true }); + } else { + window.setTimeout(installShellularDeveloperTools, 0); + } + })(); + """ + } +} + +enum BrowserViewportMode: Equatable { + case fit + case fixed(CGFloat) + + static let phone = BrowserViewportMode.fixed(390) + static let tablet = BrowserViewportMode.fixed(768) + static let minimumCustomWidth: CGFloat = 320 + static let maximumCustomWidth: CGFloat = 1_920 + + var normalized: BrowserViewportMode { + switch self { + case .fit: return .fit + case .fixed(let width): + guard width.isFinite else { return .fit } + return .fixed(min(Self.maximumCustomWidth, max(Self.minimumCustomWidth, width))) + } + } +} + +struct BrowserViewportLayout: Equatable { + let documentSize: CGSize + let pageFrame: CGRect + let allowsHorizontalScrolling: Bool +} + +enum BrowserViewportMetrics { + static func layout(in containerSize: CGSize, mode: BrowserViewportMode) -> BrowserViewportLayout { + let containerWidth = max(0, containerSize.width) + let containerHeight = max(0, containerSize.height) + switch mode.normalized { + case .fit: + return BrowserViewportLayout( + documentSize: CGSize(width: containerWidth, height: containerHeight), + pageFrame: CGRect(x: 0, y: 0, width: containerWidth, height: containerHeight), + allowsHorizontalScrolling: false + ) + case .fixed(let width): + let documentWidth = max(containerWidth, width) + return BrowserViewportLayout( + documentSize: CGSize(width: documentWidth, height: containerHeight), + pageFrame: CGRect( + x: max(0, (containerWidth - width) / 2), + y: 0, + width: width, + height: containerHeight + ), + allowsHorizontalScrolling: width > containerWidth + ) + } + } +} + +final class BrowserViewportHostView: NSView { + let webView: WKWebView + private let scrollView = NSScrollView() + private let canvasView = NSView() + var mode: BrowserViewportMode = .fit { + didSet { + mode = mode.normalized + needsLayout = true + } + } + + init(webView: WKWebView) { + self.webView = webView + super.init(frame: .zero) + wantsLayer = true + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.scrollerStyle = .overlay + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.documentView = canvasView + scrollView.autoresizingMask = [.width, .height] + addSubview(scrollView) + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [] + canvasView.addSubview(webView) + } + + required init?(coder: NSCoder) { fatalError() } + + override func layout() { + super.layout() + scrollView.frame = bounds + let availableSize = scrollView.contentView.bounds.size + let metrics = BrowserViewportMetrics.layout(in: availableSize, mode: mode) + canvasView.frame = CGRect(origin: .zero, size: metrics.documentSize) + webView.frame = metrics.pageFrame + scrollView.hasHorizontalScroller = metrics.allowsHorizontalScrolling + } + + func applyBackgroundColor(_ color: NSColor) { + layer?.backgroundColor = color.cgColor + canvasView.wantsLayer = true + canvasView.layer?.backgroundColor = color.cgColor + } +} + +enum BrowserScreenshotMetrics { + static func visiblePageRect( + in bounds: CGRect, + developerToolsVisible: Bool, + panelPercent: CGFloat + ) -> CGRect { + guard developerToolsVisible else { return bounds } + let percent = BrowserDeveloperToolsMetrics.clamp(panelPercent) / 100 + return CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: max(0, bounds.height * (1 - percent)) + ) + } +} + +enum BrowserScreenshotEncoder { + static func pngData(from image: NSImage) -> Data? { + var proposedRect = CGRect(origin: .zero, size: image.size) + guard let image = image.cgImage( + forProposedRect: &proposedRect, + context: nil, + hints: nil + ) else { return nil } + return NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) + } +} + +enum BrowserScreenshotSaveDecision { + static func destination( + for response: NSApplication.ModalResponse, + selectedURL: URL? + ) -> URL? { + response == .OK ? selectedURL : nil + } +} + +enum BrowserDeveloperToolsContextMenu { + static let inspectElementTitle = "Inspect Element" + static let clearElementHighlightTitle = "Clear Element Highlight" + static let closeDeveloperToolsTitle = "Close Developer Tools" + static let unavailableToolTip = "Developer Tools unavailable because console.js is missing" + static let initializationFailedToolTip = "Developer Tools unavailable because console.js failed to initialize" + static let developerToolsIdentifier = NSUserInterfaceItemIdentifier( + "org.shellular.browser.developer-tools" + ) + static let clearElementHighlightIdentifier = NSUserInterfaceItemIdentifier( + "org.shellular.browser.clear-element-highlight" + ) + + @discardableResult + static func addDeveloperToolsItems( + to menu: NSMenu, + target: AnyObject?, + developerToolsAction: Selector, + clearHighlightAction: Selector, + isEnabled: Bool, + isVisible: Bool, + isElementHighlighted: Bool + ) -> NSMenuItem { + let title = isVisible ? closeDeveloperToolsTitle : inspectElementTitle + let item: NSMenuItem + if let existing = menu.items.first(where: { + $0.identifier == developerToolsIdentifier || + (!$0.isSeparatorItem && [inspectElementTitle, closeDeveloperToolsTitle].contains($0.title)) + }) { + item = existing + } else { + if !menu.items.isEmpty, menu.items.last?.isSeparatorItem != true { + menu.addItem(.separator()) + } + item = NSMenuItem(title: title, action: developerToolsAction, keyEquivalent: "") + menu.addItem(item) + } + + item.identifier = developerToolsIdentifier + item.title = title + item.target = isEnabled ? target : nil + item.action = isEnabled ? developerToolsAction : nil + item.isEnabled = isEnabled + item.toolTip = isEnabled ? nil : unavailableToolTip + + let existingClearItem = menu.items.first { + $0.identifier == clearElementHighlightIdentifier || + (!$0.isSeparatorItem && $0.title == clearElementHighlightTitle) + } + if isEnabled && isVisible && isElementHighlighted { + let clearItem = existingClearItem ?? NSMenuItem( + title: clearElementHighlightTitle, + action: clearHighlightAction, + keyEquivalent: "" + ) + if clearItem.menu == nil { + menu.insertItem(clearItem, at: menu.index(of: item)) + } + clearItem.identifier = clearElementHighlightIdentifier + clearItem.title = clearElementHighlightTitle + clearItem.target = target + clearItem.action = clearHighlightAction + clearItem.isEnabled = true + clearItem.toolTip = nil + } else if let existingClearItem { + menu.removeItem(existingClearItem) + } + return item + } +} + +final class BrowserInspectableWebView: WKWebView { + var embeddedDeveloperToolsAvailable = false + var developerToolsVisibility: (() -> Bool)? + var elementHighlightVisibility: (() -> Bool)? + var onInspectElement: (() -> Void)? + var onClearElementHighlight: (() -> Void)? + var onCloseDeveloperTools: (() -> Void)? + + override func menu(for event: NSEvent) -> NSMenu? { + let menu = super.menu(for: event) ?? NSMenu() + addDeveloperToolsItem(to: menu) + return menu + } + + override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { + super.willOpenMenu(menu, with: event) + addDeveloperToolsItem(to: menu) + } + + private func addDeveloperToolsItem(to menu: NSMenu) { + BrowserDeveloperToolsContextMenu.addDeveloperToolsItems( + to: menu, + target: self, + developerToolsAction: #selector(developerToolsFromContextMenu(_:)), + clearHighlightAction: #selector(clearElementHighlightFromContextMenu(_:)), + isEnabled: embeddedDeveloperToolsAvailable, + isVisible: developerToolsVisibility?() ?? false, + isElementHighlighted: elementHighlightVisibility?() ?? false + ) + } + + @objc private func clearElementHighlightFromContextMenu(_ sender: Any?) { + guard embeddedDeveloperToolsAvailable else { return } + onClearElementHighlight?() + } + + @objc private func developerToolsFromContextMenu(_ sender: Any?) { + guard embeddedDeveloperToolsAvailable else { return } + if developerToolsVisibility?() == true { + onCloseDeveloperTools?() + } else { + onInspectElement?() + } + } +} + +enum BrowserResizeSource: Hashable { + case window + case sidebar +} + +struct BrowserResizeLifecycle { + private(set) var activeSources: Set = [] + + mutating func begin(_ source: BrowserResizeSource) -> Bool { + let wasIdle = activeSources.isEmpty + activeSources.insert(source) + return wasIdle && !activeSources.isEmpty + } + + mutating func end(_ source: BrowserResizeSource) -> Bool { + let wasActive = !activeSources.isEmpty + activeSources.remove(source) + return wasActive && activeSources.isEmpty + } +} + +enum BrowserDividerCursorRegion { + static func rect(in bounds: NSRect) -> NSRect { bounds } +} + +enum BrowserRootLayout { + static func configure(stack: NSStackView, content: NSView) { + stack.orientation = .vertical + stack.distribution = .fill + stack.spacing = 0 + content.setContentHuggingPriority(.defaultLow, for: .vertical) + content.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + } +} + +enum BrowserLiveResizePolicy { + static func apply(to window: NSWindow, views: [NSView] = []) { + // WKWebView is backed by a remote compositor. Let AppKit preserve the + // last committed window contents during a live edge resize instead of + // exposing an unpainted band while that compositor catches up. + window.preservesContentDuringLiveResize = true + for view in views { + view.layerContentsRedrawPolicy = .duringViewResize + } + } +} + +final class BrowserResizeUnderlayView: NSView { + private let contentFillView = NSView() + private let chromeFillView = NSView() + private(set) var contentColor = NSColor.windowBackgroundColor + private(set) var chromeColor = NSColor.windowBackgroundColor + var chromeHeight: CGFloat = 40 { + didSet { needsLayout = true } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + [contentFillView, chromeFillView].forEach { + $0.wantsLayer = true + addSubview($0) + } + apply(content: contentColor, chrome: chromeColor) + } + + required init?(coder: NSCoder) { fatalError() } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func layout() { + super.layout() + CATransaction.begin() + CATransaction.setDisableActions(true) + contentFillView.frame = bounds + chromeFillView.frame = NSRect( + x: bounds.minX, + y: max(bounds.minY, bounds.maxY - min(chromeHeight, bounds.height)), + width: bounds.width, + height: min(chromeHeight, bounds.height) + ) + CATransaction.commit() + } + + func apply(content: NSColor, chrome: NSColor) { + contentColor = content + chromeColor = chrome + contentFillView.layer?.backgroundColor = content.cgColor + chromeFillView.layer?.backgroundColor = chrome.cgColor + } + + var contentFillFrame: NSRect { contentFillView.frame } + var chromeFillFrame: NSRect { chromeFillView.frame } +} + +enum TitlebarDragRegion { + static func frame( + in workbenchBounds: NSRect, + trafficLightTrailingEdge: CGFloat, + height: CGFloat = 40, + trailingInset: CGFloat = 12 + ) -> NSRect { + let leading = ceil(trafficLightTrailingEdge + 10) + return NSRect( + x: leading, + y: max(workbenchBounds.minY, workbenchBounds.maxY - height), + width: max(0, workbenchBounds.width - leading - trailingInset), + height: min(height, workbenchBounds.height) + ) + } +} + +enum BrowserTrustEvaluationScheduler { + static func schedule( + on queue: DispatchQueue, + _ operation: @escaping (DispatchQueue) -> Void + ) { + queue.async { + operation(queue) + } + } +} + +enum BrowserAddressResolver { + private static let allowedSchemes = Set(["http", "https", "shellular"]) + + static func resolve(_ input: String) -> URL? { + let value = input.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { return URL(string: "shellular://home") } + + if hasExplicitScheme(value) { + guard var components = URLComponents(string: value), + let scheme = components.scheme?.lowercased(), + allowedSchemes.contains(scheme) else { return nil } + normalizeUnspecifiedHost(&components) + return components.url + } + + if value.contains(where: \.isWhitespace) { + return searchURL(value) + } + + let candidate = candidateComponents(value) + guard let rawHost = candidate?.host?.lowercased() else { + return searchURL(value) + } + let host = unbracketedHost(rawHost) + guard !host.isEmpty else { return searchURL(value) } + let hasExplicitPort = + value.range(of: #"(?:\]|\w):\d+(?:[/?#]|$)"#, options: .regularExpression) != nil + let looksLikeHost = + hasExplicitPort || + host.contains(".") || + host == "localhost" || + host.hasSuffix(".localhost") || + host.hasSuffix(".local") || + isIPAddress(host) + guard looksLikeHost else { return searchURL(value) } + + let scheme = isDevelopmentHost(host) || hasExplicitPort ? "http" : "https" + let address = (value == "::1" || value == "::") ? "[\(value)]" : value + var components = URLComponents(string: "\(scheme)://\(address)") + normalizeUnspecifiedHost(&components) + return components?.url + } + + static func isRestorable(_ value: String) -> Bool { + guard let url = URL(string: value), let scheme = url.scheme?.lowercased() else { + return false + } + if scheme == "shellular" { return !(url.host ?? "").isEmpty } + return scheme == "http" || scheme == "https" + } + + private static func candidateComponents(_ value: String) -> URLComponents? { + if value == "::1" || value == "::" { + return URLComponents(string: "http://[\(value)]") + } + return URLComponents(string: "http://\(value)") + } + + private static func hasExplicitScheme(_ value: String) -> Bool { + guard let match = value.range( + of: #"^[A-Za-z][A-Za-z0-9+.-]*:"#, + options: .regularExpression + ) else { return false } + let remainder = value[match.upperBound...] + // A hostname followed by a numeric port is not a URL scheme, for example + // localhost:3000 or app.test:8443/path. + if let first = remainder.first, first.isNumber { + return false + } + return true + } + + private static func normalizeUnspecifiedHost(_ components: inout URLComponents?) { + guard var value = components else { return } + let host = unbracketedHost(value.host?.lowercased() ?? "") + if host == "0.0.0.0" || host == "::" { + value.host = "localhost" + } + components = value + } + + private static func normalizeUnspecifiedHost(_ components: inout URLComponents) { + let host = unbracketedHost(components.host?.lowercased() ?? "") + if host == "0.0.0.0" || host == "::" { + components.host = "localhost" + } + } + + private static func unbracketedHost(_ host: String) -> String { + guard host.hasPrefix("["), host.hasSuffix("]") else { return host } + return String(host.dropFirst().dropLast()) + } + + private static func isDevelopmentHost(_ host: String) -> Bool { + if host == "localhost" || + host.hasSuffix(".localhost") || + host.hasSuffix(".local") || + host == "0.0.0.0" || + host == "::" || + host == "::1" || + host.hasPrefix("fe80:") || + host.hasPrefix("fc") || + host.hasPrefix("fd") { + return true + } + guard let octets = ipv4Octets(host) else { return false } + return octets[0] == 10 || + octets[0] == 127 || + (octets[0] == 169 && octets[1] == 254) || + (octets[0] == 172 && (16...31).contains(octets[1])) || + (octets[0] == 192 && octets[1] == 168) + } + + private static func isIPAddress(_ host: String) -> Bool { + ipv4Octets(host) != nil || host.contains(":") + } + + private static func ipv4Octets(_ host: String) -> [Int]? { + let values = host.split(separator: ".", omittingEmptySubsequences: false) + .compactMap { Int($0) } + guard values.count == 4, values.allSatisfy({ (0...255).contains($0) }) else { + return nil + } + return values + } + + private static func searchURL(_ query: String) -> URL? { + var components = URLComponents(string: "https://www.google.com/search") + components?.queryItems = [URLQueryItem(name: "q", value: query)] + return components?.url + } +} + +enum BrowserSidebarMetrics { + static let persistenceKey = "shellular.browser.sidebarWidth.v1" + static let defaultWidth: CGFloat = 400 + static let minimumWidth: CGFloat = 280 + static let maximumWindowFraction: CGFloat = 0.7 + static let minimumWorkbenchWidth: CGFloat = 360 + + static func clampedWidth(_ width: CGFloat, containerWidth: CGFloat) -> CGFloat { + let maximum = max( + minimumWidth, + min( + containerWidth * maximumWindowFraction, + containerWidth - minimumWorkbenchWidth + ) + ) + return min(maximum, max(minimumWidth, width)) + } + + static func layout( + in bounds: NSRect, + preferredWidth: CGFloat, + isVisible: Bool + ) -> BrowserSidebarLayout { + let sidebarWidth = clampedWidth(preferredWidth, containerWidth: bounds.width) + let occupiedWidth = isVisible ? sidebarWidth : 0 + let workbenchWidth = max(0, bounds.width - occupiedWidth) + return BrowserSidebarLayout( + workbenchFrame: NSRect( + x: bounds.minX, + y: bounds.minY, + width: workbenchWidth, + height: bounds.height + ), + sidebarFrame: NSRect( + x: bounds.minX + workbenchWidth, + y: bounds.minY, + width: sidebarWidth, + height: bounds.height + ), + dividerFrame: NSRect( + x: max(bounds.minX, bounds.minX + workbenchWidth - 4), + y: bounds.minY, + width: 8, + height: bounds.height + ) + ) + } +} + +struct BrowserSidebarLayout: Equatable { + let workbenchFrame: NSRect + let sidebarFrame: NSRect + let dividerFrame: NSRect +} + +struct BrowserSidebarWidthStore { + private let defaults: UserDefaults + private let key: String + + init( + defaults: UserDefaults = .standard, + key: String = BrowserSidebarMetrics.persistenceKey + ) { + self.defaults = defaults + self.key = key + } + + func load() -> CGFloat { + let value = defaults.double(forKey: key) + return value > 0 ? CGFloat(value) : BrowserSidebarMetrics.defaultWidth + } + + func save(_ value: CGFloat) { + defaults.set(Double(value), forKey: key) + } +} + +enum BrowserPresentation: Equatable { + case sidebar + case window +} + +struct BrowserPresentationState: Equatable { + private(set) var value: BrowserPresentation = .sidebar + + mutating func moveToSidebar() { + value = .sidebar + } + + mutating func moveToWindow() { + value = .window + } +} + +struct BrowserChromePalette { + let primary: NSColor + let secondary: NSColor + let surfaceSoft: NSColor + let chromeBackground: NSColor + let primaryText: NSColor + let secondaryText: NSColor + let lineSoft: NSColor + let accent: NSColor + let danger: NSColor + let tabHoverBackground: NSColor + let tabSelectedBackground: NSColor + let tabPressedBackground: NSColor + + init(_ theme: [String: Any]) { + primary = Self.color(theme, keys: ["primary"], fallback: .windowBackgroundColor) + surfaceSoft = Self.color( + theme, + keys: ["surfaceSoft", "secondary"], + fallback: .controlBackgroundColor + ) + secondary = Self.color(theme, keys: ["secondary"], fallback: surfaceSoft) + chromeBackground = Self.mix(secondary, primary, firstWeight: 0.94) + primaryText = Self.color(theme, keys: ["primaryText"], fallback: .labelColor) + secondaryText = Self.color( + theme, + keys: ["secondaryText", "textMuted"], + fallback: .secondaryLabelColor + ) + lineSoft = Self.color( + theme, + keys: ["lineSoft", "cardBorder"], + fallback: .separatorColor + ) + accent = Self.color( + theme, + keys: ["accent", "primaryActiveText", "link"], + fallback: .controlAccentColor + ) + danger = Self.color(theme, keys: ["danger"], fallback: .systemRed) + tabHoverBackground = Self.opaqueMix( + primaryText, + chromeBackground, + foregroundWeight: 0.10 + ) + tabSelectedBackground = Self.opaqueMix( + accent, + chromeBackground, + foregroundWeight: 0.16 + ) + tabPressedBackground = Self.opaqueMix( + accent, + chromeBackground, + foregroundWeight: 0.24 + ) + } + + var separator: NSColor { lineSoft } + + private static func mix( + _ first: NSColor, + _ second: NSColor, + firstWeight: CGFloat + ) -> NSColor { + guard let first = first.usingColorSpace(.sRGB), + let second = second.usingColorSpace(.sRGB) else { return first } + let firstWeight = min(1, max(0, firstWeight)) + let secondWeight = 1 - firstWeight + return NSColor( + srgbRed: first.redComponent * firstWeight + second.redComponent * secondWeight, + green: first.greenComponent * firstWeight + second.greenComponent * secondWeight, + blue: first.blueComponent * firstWeight + second.blueComponent * secondWeight, + alpha: first.alphaComponent * firstWeight + second.alphaComponent * secondWeight + ) + } + + private static func opaqueMix( + _ foreground: NSColor, + _ background: NSColor, + foregroundWeight: CGFloat + ) -> NSColor { + guard let foreground = foreground.usingColorSpace(.sRGB), + let background = background.usingColorSpace(.sRGB) else { + return background.withAlphaComponent(1) + } + let foregroundWeight = min(1, max(0, foregroundWeight)) + let backgroundWeight = 1 - foregroundWeight + return NSColor( + srgbRed: foreground.redComponent * foregroundWeight + + background.redComponent * backgroundWeight, + green: foreground.greenComponent * foregroundWeight + + background.greenComponent * backgroundWeight, + blue: foreground.blueComponent * foregroundWeight + + background.blueComponent * backgroundWeight, + alpha: 1 + ) + } + + private static func color( + _ theme: [String: Any], + keys: [String], + fallback: NSColor + ) -> NSColor { + for key in keys { + guard let rawValue = theme[key] as? String, + let components = NativeThemeColorParser.parse(rawValue) else { continue } + return NSColor( + srgbRed: CGFloat(components.red), + green: CGFloat(components.green), + blue: CGFloat(components.blue), + alpha: CGFloat(components.alpha) + ) + } + return fallback + } +} + +class BrowserNonDraggableButton: NSButton { + override var mouseDownCanMoveWindow: Bool { false } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} + +final class BrowserTabHoverButton: BrowserNonDraggableButton { + private var hoverTrackingArea: NSTrackingArea? + private(set) var isPointerInside = false + var onStateChange: (() -> Void)? + + override func highlight(_ flag: Bool) { + super.highlight(flag) + onStateChange?() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let hoverTrackingArea { removeTrackingArea(hoverTrackingArea) } + let trackingArea = NSTrackingArea( + rect: .zero, + options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + hoverTrackingArea = trackingArea + } + + override func mouseEntered(with event: NSEvent) { + guard !isPointerInside else { return } + isPointerInside = true + onStateChange?() + } + + override func mouseExited(with event: NSEvent) { + guard isPointerInside else { return } + isPointerInside = false + onStateChange?() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window == nil, isPointerInside else { return } + isPointerInside = false + onStateChange?() + } +} + +private final class BrowserTabDecorationView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +private final class BrowserTabDecorationLabel: NSTextField { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +struct BrowserTabItemState { + let id: String + let title: String + let isSelected: Bool + let statusColor: NSColor +} + +final class BrowserTabItemView: NSView { + let id: String + let selectionButton = BrowserTabHoverButton() + let closeButton = BrowserTabHoverButton() + private let statusView = BrowserTabDecorationView() + private let titleLabel = BrowserTabDecorationLabel(labelWithString: "") + private var onSelect: ((String) -> Void)? + private var onClose: ((String) -> Void)? + private var palette = BrowserChromePalette([:]) + private(set) var isSelected = false + private(set) var displayedBackgroundColor = NSColor.clear + var displayedTitle: String { titleLabel.stringValue } + var displayedTextColor: NSColor { titleLabel.textColor ?? .clear } + var isHoverHighlighted: Bool { isPointerInside } + var titleFrame: NSRect { titleLabel.convert(titleLabel.bounds, to: self) } + var statusFrame: NSRect { statusView.convert(statusView.bounds, to: self) } + + private var isPointerInside: Bool { + selectionButton.isPointerInside || closeButton.isPointerInside + } + + private var isPressed: Bool { + selectionButton.isHighlighted || closeButton.isHighlighted + } + + override var mouseDownCanMoveWindow: Bool { false } + + init(id: String) { + self.id = id + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 6 + translatesAutoresizingMaskIntoConstraints = false + + selectionButton.title = "" + selectionButton.isBordered = false + selectionButton.bezelStyle = .regularSquare + selectionButton.focusRingType = .none + selectionButton.target = self + selectionButton.action = #selector(selectAction) + selectionButton.setAccessibilityElement(false) + selectionButton.onStateChange = { [weak self] in self?.updateAppearance() } + statusView.wantsLayer = true + statusView.layer?.cornerRadius = 3 + titleLabel.alignment = .left + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.cell?.usesSingleLineMode = true + closeButton.image = NSImage( + systemSymbolName: "xmark", + accessibilityDescription: "Close Tab" + ) + closeButton.imagePosition = .imageOnly + closeButton.title = "" + closeButton.isBordered = false + closeButton.toolTip = "Close Tab" + closeButton.setAccessibilityLabel("Close Tab") + closeButton.target = self + closeButton.action = #selector(closeAction) + closeButton.onStateChange = { [weak self] in self?.updateAppearance() } + + [selectionButton, statusView, titleLabel, closeButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + NSLayoutConstraint.activate([ + widthAnchor.constraint(greaterThanOrEqualToConstant: 90), + widthAnchor.constraint(lessThanOrEqualToConstant: 220), + heightAnchor.constraint(equalToConstant: 29), + selectionButton.leadingAnchor.constraint(equalTo: leadingAnchor), + selectionButton.trailingAnchor.constraint(equalTo: trailingAnchor), + selectionButton.topAnchor.constraint(equalTo: topAnchor), + selectionButton.bottomAnchor.constraint(equalTo: bottomAnchor), + statusView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + statusView.centerYAnchor.constraint(equalTo: centerYAnchor), + statusView.widthAnchor.constraint(equalToConstant: 6), + statusView.heightAnchor.constraint(equalToConstant: 6), + titleLabel.leadingAnchor.constraint(equalTo: statusView.trailingAnchor, constant: 5), + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + titleLabel.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -2), + closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -5), + closeButton.centerYAnchor.constraint(equalTo: centerYAnchor), + closeButton.widthAnchor.constraint(equalToConstant: 18), + ]) + } + + required init?(coder: NSCoder) { fatalError() } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = superview.map { convert(point, from: $0) } ?? point + guard !isHidden, alphaValue > 0, bounds.contains(localPoint) else { return nil } + let closePoint = closeButton.convert(localPoint, from: self) + if closeButton.bounds.contains(closePoint) { + return closeButton + } + return selectionButton + } + + override func accessibilityPerformPress() -> Bool { + onSelect?(id) + return true + } + + func update( + state: BrowserTabItemState, + palette: BrowserChromePalette, + onSelect: @escaping (String) -> Void, + onClose: @escaping (String) -> Void + ) { + self.onSelect = onSelect + self.onClose = onClose + self.palette = palette + isSelected = state.isSelected + titleLabel.stringValue = state.title.isEmpty ? "New Tab" : state.title + titleLabel.font = .systemFont(ofSize: 12, weight: state.isSelected ? .medium : .regular) + setAccessibilityRole(.radioButton) + setAccessibilityLabel(titleLabel.stringValue) + setAccessibilitySelected(state.isSelected) + statusView.layer?.backgroundColor = state.statusColor.cgColor + updateAppearance() + } + + @objc private func selectAction() { onSelect?(id) } + @objc private func closeAction() { onClose?(id) } + + private func updateAppearance() { + let color: NSColor + if isPressed { + color = palette.tabPressedBackground + } else if isSelected { + color = palette.tabSelectedBackground + } else if isPointerInside { + color = palette.tabHoverBackground + } else { + color = palette.chromeBackground + } + let foreground = (isSelected || isPointerInside || isPressed) + ? palette.primaryText + : palette.secondaryText + displayedBackgroundColor = color + layer?.backgroundColor = color.cgColor + titleLabel.textColor = foreground + closeButton.contentTintColor = foreground + } +} + +enum BrowserThemeScript { + static func variables(from theme: [String: Any]) -> [String: String] { + theme.reduce(into: [:]) { result, entry in + guard let value = entry.value as? String else { return } + result[cssVariableName(for: entry.key)] = value + } + } + + static func make(from theme: [String: Any]) -> String? { + let variables = variables(from: theme) + guard JSONSerialization.isValidJSONObject(variables), + let data = try? JSONSerialization.data(withJSONObject: variables, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { return nil } + return """ + (() => { + const variables = \(json); + const root = document.documentElement; + for (const [name, value] of Object.entries(variables)) { + root.style.setProperty(name, value); + } + return true; + })(); + """ + } + + static func cssVariableName(for key: String) -> String { + if key.hasPrefix("--") { return key } + let kebab = key.reduce(into: "") { result, character in + if character.isUppercase { + result.append("-") + result.append(contentsOf: character.lowercased()) + } else { + result.append(character) + } + } + return "--\(kebab)" + } +} + +final class BrowserAddressBar: NSView { + let securityButton = NSButton() + let textField = NSTextField() + let clearButton = NSButton() + private(set) var isFocused = false + private(set) var hasValidationError = false + private var palette = BrowserChromePalette([:]) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 6 + layer?.borderWidth = 1 + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + configureButton(securityButton, symbol: "info.circle", label: "Connection security") + configureButton(clearButton, symbol: "xmark.circle.fill", label: "Clear address") + clearButton.isHidden = true + + textField.placeholderString = "Search Google or enter an address" + textField.font = .systemFont(ofSize: 13) + textField.isBezeled = false + textField.isBordered = false + textField.drawsBackground = false + textField.focusRingType = .none + textField.lineBreakMode = .byTruncatingTail + textField.cell?.usesSingleLineMode = true + textField.setContentHuggingPriority(.defaultLow, for: .horizontal) + textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + [securityButton, textField, clearButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + translatesAutoresizingMaskIntoConstraints = false + heightAnchor.constraint(equalToConstant: 36).isActive = true + NSLayoutConstraint.activate([ + securityButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5), + securityButton.centerYAnchor.constraint(equalTo: centerYAnchor), + securityButton.widthAnchor.constraint(equalToConstant: 26), + securityButton.heightAnchor.constraint(equalToConstant: 26), + textField.leadingAnchor.constraint(equalTo: securityButton.trailingAnchor, constant: 2), + textField.centerYAnchor.constraint(equalTo: centerYAnchor), + textField.trailingAnchor.constraint(equalTo: clearButton.leadingAnchor, constant: -2), + clearButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -5), + clearButton.centerYAnchor.constraint(equalTo: centerYAnchor), + clearButton.widthAnchor.constraint(equalToConstant: 26), + clearButton.heightAnchor.constraint(equalToConstant: 26), + ]) + applyPalette(palette) + } + + required init?(coder: NSCoder) { fatalError() } + + func applyPalette(_ palette: BrowserChromePalette) { + self.palette = palette + textField.textColor = hasValidationError ? palette.danger : palette.primaryText + textField.placeholderAttributedString = NSAttributedString( + string: "Search Google or enter an address", + attributes: [ + .foregroundColor: palette.secondaryText.withAlphaComponent(0.6), + .font: NSFont.systemFont(ofSize: 13), + ] + ) + securityButton.contentTintColor = palette.secondaryText + clearButton.contentTintColor = palette.secondaryText + updateLayerColors() + } + + func setFocused(_ focused: Bool) { + isFocused = focused + updateLayerColors() + } + + func setValidationError(_ invalid: Bool) { + hasValidationError = invalid + textField.textColor = invalid ? palette.danger : palette.primaryText + updateLayerColors() + } + + func updateClearButton(isEditing: Bool) { + clearButton.isHidden = !isEditing || textField.stringValue.isEmpty + } + + func setSecurity(symbol: String, help: String, color: NSColor) { + securityButton.image = NSImage( + systemSymbolName: symbol, + accessibilityDescription: help + ) + securityButton.toolTip = help + securityButton.setAccessibilityLabel(help) + securityButton.contentTintColor = color + } + + private func updateLayerColors() { + layer?.backgroundColor = (isFocused ? palette.primary : palette.surfaceSoft).cgColor + layer?.borderColor = (hasValidationError ? palette.danger : isFocused ? palette.accent : palette.lineSoft).cgColor + } + + private func configureButton(_ button: NSButton, symbol: String, label: String) { + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + button.imagePosition = .imageOnly + button.title = "" + button.isBordered = false + button.bezelStyle = .texturedRounded + button.toolTip = label + button.setAccessibilityLabel(label) + } +} + +final class BrowserTabHeaderView: NSView { + let tabStack = NSStackView() + let addButton = BrowserNonDraggableButton() + let tabListButton = BrowserNonDraggableButton() + let presentationButton = BrowserNonDraggableButton() + let closeButton = BrowserNonDraggableButton() + private let scrollView = NSScrollView() + private let tabDocumentView = NSView() + private let actionsStack = NSStackView() + private let separatorView = NSView() + private var scrollLeadingConstraint: NSLayoutConstraint! + private var tabItemsByID: [String: BrowserTabItemView] = [:] + private var orderedTabIDs: [String] = [] + private var selectedTabID: String? + private(set) var presentation: BrowserPresentation = .sidebar + private(set) var palette = BrowserChromePalette([:]) + + override var mouseDownCanMoveWindow: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + translatesAutoresizingMaskIntoConstraints = false + heightAnchor.constraint(equalToConstant: 40).isActive = true + + scrollView.drawsBackground = false + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = false + scrollView.horizontalScrollElasticity = .automatic + scrollView.verticalScrollElasticity = .none + scrollView.documentView = tabDocumentView + + tabStack.orientation = .horizontal + tabStack.alignment = .centerY + tabStack.spacing = 3 + tabStack.translatesAutoresizingMaskIntoConstraints = false + tabDocumentView.addSubview(tabStack) + + actionsStack.orientation = .horizontal + actionsStack.alignment = .centerY + actionsStack.spacing = 2 + actionsStack.setContentHuggingPriority(.required, for: .horizontal) + actionsStack.setContentCompressionResistancePriority(.required, for: .horizontal) + [addButton, tabListButton, presentationButton, closeButton].forEach { + configureButton($0) + actionsStack.addArrangedSubview($0) + } + + [scrollView, actionsStack, separatorView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + scrollLeadingConstraint = scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6) + NSLayoutConstraint.activate([ + scrollLeadingConstraint, + scrollView.topAnchor.constraint(equalTo: topAnchor, constant: 5), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + scrollView.trailingAnchor.constraint(equalTo: actionsStack.leadingAnchor, constant: -4), + actionsStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), + actionsStack.centerYAnchor.constraint(equalTo: centerYAnchor), + separatorView.leadingAnchor.constraint(equalTo: leadingAnchor), + separatorView.trailingAnchor.constraint(equalTo: trailingAnchor), + separatorView.bottomAnchor.constraint(equalTo: bottomAnchor), + separatorView.heightAnchor.constraint(equalToConstant: 1), + tabStack.leadingAnchor.constraint(equalTo: tabDocumentView.leadingAnchor), + tabStack.trailingAnchor.constraint(equalTo: tabDocumentView.trailingAnchor), + tabStack.topAnchor.constraint(equalTo: tabDocumentView.topAnchor), + tabStack.bottomAnchor.constraint(equalTo: tabDocumentView.bottomAnchor), + ]) + applyPresentation(.sidebar) + applyPalette(palette) + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + window?.acceptsMouseMovedEvents = true + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = superview.map { convert(point, from: $0) } ?? point + if scrollView.frame.contains(localPoint) { + for case let item as BrowserTabItemView in tabStack.arrangedSubviews.reversed() { + let itemPoint = item.convert(localPoint, from: self) + if item.bounds.contains(itemPoint) { + let closePoint = item.closeButton.convert(itemPoint, from: item) + return item.closeButton.bounds.contains(closePoint) + ? item.closeButton + : item.selectionButton + } + } + } + return super.hitTest(point) + } + + override func layout() { + super.layout() + let viewport = scrollView.contentView.bounds.size + let fitting = tabStack.fittingSize + let documentSize = NSSize( + width: max(viewport.width, fitting.width), + height: max(viewport.height, fitting.height) + ) + if tabDocumentView.frame.size != documentSize { + tabDocumentView.frame = NSRect(origin: .zero, size: documentSize) + } + tabDocumentView.layoutSubtreeIfNeeded() + } + + func applyPresentation(_ presentation: BrowserPresentation) { + self.presentation = presentation + scrollLeadingConstraint.constant = presentation == .window ? 78 : 6 + closeButton.isHidden = presentation == .window + let symbol = presentation == .window ? "sidebar.right" : "arrow.up.right.square" + let help = presentation == .window ? "Move to Sidebar" : "Open in Separate Window" + presentationButton.image = NSImage(systemSymbolName: symbol, accessibilityDescription: help) + presentationButton.toolTip = help + presentationButton.setAccessibilityLabel(help) + needsLayout = true + } + + func applyPalette(_ palette: BrowserChromePalette) { + self.palette = palette + layer?.backgroundColor = palette.chromeBackground.cgColor + separatorView.wantsLayer = true + separatorView.layer?.backgroundColor = palette.lineSoft.cgColor + [addButton, tabListButton, presentationButton, closeButton].forEach { + $0.contentTintColor = palette.secondaryText + } + } + + func reconcileTabs( + _ states: [BrowserTabItemState], + onSelect: @escaping (String) -> Void, + onClose: @escaping (String) -> Void + ) { + let nextIDs = states.map(\.id) + let structureChanged = nextIDs != orderedTabIDs + let validIDs = Set(nextIDs) + for id in orderedTabIDs where !validIDs.contains(id) { + guard let item = tabItemsByID.removeValue(forKey: id) else { continue } + tabStack.removeArrangedSubview(item) + item.removeFromSuperview() + } + + if structureChanged { + for (index, state) in states.enumerated() { + let item = tabItemsByID[state.id] ?? BrowserTabItemView(id: state.id) + tabItemsByID[state.id] = item + if !tabStack.arrangedSubviews.contains(where: { $0 === item }) { + tabStack.insertArrangedSubview(item, at: min(index, tabStack.arrangedSubviews.count)) + } else if tabStack.arrangedSubviews.firstIndex(where: { $0 === item }) != index { + tabStack.removeArrangedSubview(item) + tabStack.insertArrangedSubview(item, at: min(index, tabStack.arrangedSubviews.count)) + } + } + orderedTabIDs = nextIDs + } + + for state in states { + tabItemsByID[state.id]?.update( + state: state, + palette: palette, + onSelect: onSelect, + onClose: onClose + ) + } + + let nextSelectedID = states.first(where: \.isSelected)?.id + let shouldScroll = nextSelectedID != selectedTabID || structureChanged + selectedTabID = nextSelectedID + needsLayout = true + guard shouldScroll, let nextSelectedID, + let selectedItem = tabItemsByID[nextSelectedID] else { return } + DispatchQueue.main.async { [weak self, weak selectedItem] in + guard let self, let selectedItem else { return } + self.scrollTabToVisible(selectedItem) + } + } + + func tabItem(withID id: String) -> BrowserTabItemView? { + tabItemsByID[id] + } + + func scrollTabToVisible(_ tabView: NSView) { + layoutSubtreeIfNeeded() + tabDocumentView.layoutSubtreeIfNeeded() + let rect = tabView.convert(tabView.bounds, to: tabDocumentView) + scrollView.contentView.scrollToVisible(rect.insetBy(dx: -4, dy: 0)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + private func configureButton(_ button: NSButton) { + button.imagePosition = .imageOnly + button.title = "" + button.isBordered = false + button.bezelStyle = .texturedRounded + button.translatesAutoresizingMaskIntoConstraints = false + button.widthAnchor.constraint(equalToConstant: 30).isActive = true + button.heightAnchor.constraint(equalToConstant: 30).isActive = true + } +} + +struct BrowserTrustException: Hashable { + let host: String + let port: Int + let certificateFingerprint: String +} + +final class BrowserTrustExceptionStore { + private var values = Set() + + func approve(_ exception: BrowserTrustException) { + values.insert(exception) + } + + func contains(_ exception: BrowserTrustException) -> Bool { + values.contains(exception) + } + + func removeAll() { + values.removeAll() + } +} + +final class BrowserOneTimeTokenStore { + private var tokens = Set() + + func issue() -> String { + let token = UUID().uuidString + tokens.insert(token) + return token + } + + func consume(_ token: String) -> Bool { + tokens.remove(token) != nil + } +} diff --git a/platforms/macos/shellular/ContextMenuSupport.swift b/platforms/macos/shellular/ContextMenuSupport.swift new file mode 100644 index 0000000..006b7c9 --- /dev/null +++ b/platforms/macos/shellular/ContextMenuSupport.swift @@ -0,0 +1,141 @@ +import AppKit + +struct ContextMenuViewportMetrics: Equatable { + var layoutWidth: CGFloat + var layoutHeight: CGFloat + var visualWidth: CGFloat + var visualHeight: CGFloat + var visualOffsetLeft: CGFloat + var visualOffsetTop: CGFloat + var visualScale: CGFloat + var deviceScaleFactor: CGFloat +} + +enum ContextMenuCoordinateConverter { + /// Converts a layout-viewport CSS point into the target NSView's logical + /// coordinate space. AppKit works in points, so Retina backing scale must + /// not be applied a second time. + static func convert( + _ layoutPoint: CGPoint, + viewport: ContextMenuViewportMetrics, + viewBounds: CGRect, + isFlipped: Bool, + edgeInset: CGFloat = 1 + ) -> CGPoint { + let visualScale = positive(viewport.visualScale, fallback: 1) + let visualWidth = positive( + viewport.visualWidth, + fallback: viewport.layoutWidth / visualScale + ) + let visualHeight = positive( + viewport.visualHeight, + fallback: viewport.layoutHeight / visualScale + ) + let scaleX = positive(viewBounds.width, fallback: visualWidth) / visualWidth + let scaleY = positive(viewBounds.height, fallback: visualHeight) / visualHeight + let xFromLeft = (layoutPoint.x - viewport.visualOffsetLeft) * scaleX + let yFromTop = (layoutPoint.y - viewport.visualOffsetTop) * scaleY + let unclampedX = viewBounds.minX + xFromLeft + let unclampedY = isFlipped + ? viewBounds.minY + yFromTop + : viewBounds.maxY - yFromTop + return CGPoint( + x: clamp(unclampedX, min: viewBounds.minX + edgeInset, max: viewBounds.maxX - edgeInset), + y: clamp(unclampedY, min: viewBounds.minY + edgeInset, max: viewBounds.maxY - edgeInset) + ) + } + + private static func positive(_ value: CGFloat, fallback: CGFloat) -> CGFloat { + value.isFinite && value > 0 ? value : max(1, fallback) + } + + private static func clamp(_ value: CGFloat, min lower: CGFloat, max upper: CGFloat) -> CGFloat { + guard lower <= upper else { return (lower + upper) / 2 } + return Swift.min(Swift.max(value, lower), upper) + } +} + +enum ContextMenuCoordinatorAction: Equatable { + case present(Int) + case cancelTracking(Int) + case complete(Int, String?) +} + +/// Pure request state machine. `NSMenu` runs a nested event loop, so a new +/// bridge request can arrive while the previous call to `popUp` is still on +/// the stack. This coordinator guarantees one active tracker and exactly one +/// completion per request. +struct ContextMenuRequestCoordinator { + private(set) var activeID: Int? + private(set) var pendingID: Int? + private var activeCompleted = false + private var cancellationRequested = false + + func canPresent(_ id: Int) -> Bool { + activeID == id && !cancellationRequested + } + + func canSelect(_ id: Int) -> Bool { + activeID == id && !activeCompleted && !cancellationRequested + } + + mutating func submit(_ id: Int) -> [ContextMenuCoordinatorAction] { + guard let activeID else { + self.activeID = id + activeCompleted = false + cancellationRequested = false + return [.present(id)] + } + + var actions: [ContextMenuCoordinatorAction] = [] + if let pendingID { + actions.append(.complete(pendingID, nil)) + } + pendingID = id + if !activeCompleted { + activeCompleted = true + actions.append(.complete(activeID, nil)) + } + if !cancellationRequested { + cancellationRequested = true + actions.append(.cancelTracking(activeID)) + } + return actions + } + + mutating func trackingEnded(_ id: Int, selection: String?) -> [ContextMenuCoordinatorAction] { + guard activeID == id else { return [] } + var actions: [ContextMenuCoordinatorAction] = [] + if !activeCompleted { + actions.append(.complete(id, selection)) + } + activeID = nil + activeCompleted = false + cancellationRequested = false + if let pendingID { + self.pendingID = nil + activeID = pendingID + actions.append(.present(pendingID)) + } + return actions + } + + mutating func cancelAll() -> [ContextMenuCoordinatorAction] { + var actions: [ContextMenuCoordinatorAction] = [] + if let pendingID { + actions.append(.complete(pendingID, nil)) + self.pendingID = nil + } + if let activeID { + if !activeCompleted { + activeCompleted = true + actions.append(.complete(activeID, nil)) + } + if !cancellationRequested { + cancellationRequested = true + actions.append(.cancelTracking(activeID)) + } + } + return actions + } +} diff --git a/platforms/macos/shellular/DesktopShortcutSupport.swift b/platforms/macos/shellular/DesktopShortcutSupport.swift new file mode 100644 index 0000000..8f74aec --- /dev/null +++ b/platforms/macos/shellular/DesktopShortcutSupport.swift @@ -0,0 +1,107 @@ +import AppKit + +struct DesktopNativeShortcut: Equatable { + let command: String + let key: String + let modifiers: NSEvent.ModifierFlags + + init(command: String, key: String, modifiers: NSEvent.ModifierFlags) { + self.command = command + self.key = key + self.modifiers = modifiers + } + + init?(command: String, payload: Any) { + guard let raw = payload as? [String: Any], + let key = raw["key"] as? String, + !key.isEmpty else { return nil } + let names = raw["modifiers"] as? [String] ?? [] + var modifiers: NSEvent.ModifierFlags = [] + for name in names { + switch name { + case "meta": modifiers.insert(.command) + case "ctrl": modifiers.insert(.control) + case "alt": modifiers.insert(.option) + case "shift": modifiers.insert(.shift) + default: continue + } + } + self.init(command: command, key: key.lowercased(), modifiers: modifiers) + } +} + +enum DesktopShortcutCatalog { + static let contextualNew = shortcut("contextual-new", "n", [.command]) + static let newFile = shortcut("new-file", "", []) + static let newChat = shortcut("new-chat", "", []) + static let toggleTerminal = shortcut("toggle-terminal", "`", [.control]) + static let newTerminal = shortcut("new-terminal", "`", [.control, .shift]) + static let openFile = shortcut("open-file", "o", [.command]) + static let save = shortcut("save", "s", [.command]) + static let closeTab = shortcut("close-tab", "w", [.command]) + static let undo = shortcut("undo", "z", [.command]) + static let redo = shortcut("redo", "z", [.command, .shift]) + static let cut = shortcut("cut", "x", [.command]) + static let copy = shortcut("copy", "c", [.command]) + static let paste = shortcut("paste", "v", [.command]) + static let selectAll = shortcut("select-all", "a", [.command]) + static let toggleSidebar = shortcut("toggle-sidebar", "b", [.command]) + static let showExplorer = shortcut("show-explorer", "e", [.command, .shift]) + static let projectSearch = shortcut("project-search", "f", [.command, .shift]) + static let showSourceControl = shortcut("show-source-control", "g", [.control, .shift]) + static let settings = shortcut("settings", ",", [.command]) + + static let defaults: [String: DesktopNativeShortcut] = [ + contextualNew.command: contextualNew, + newFile.command: newFile, + newChat.command: newChat, + toggleTerminal.command: toggleTerminal, + newTerminal.command: newTerminal, + openFile.command: openFile, + save.command: save, + closeTab.command: closeTab, + undo.command: undo, + redo.command: redo, + cut.command: cut, + copy.command: copy, + paste.command: paste, + selectAll.command: selectAll, + toggleSidebar.command: toggleSidebar, + showExplorer.command: showExplorer, + projectSearch.command: projectSearch, + showSourceControl.command: showSourceControl, + settings.command: settings, + ] + + static func resolved( + payload: [String: Any]? + ) -> [String: DesktopNativeShortcut] { + guard let payload else { return defaults } + return payload.reduce(into: [:]) { result, entry in + if let shortcut = DesktopNativeShortcut( + command: entry.key, + payload: entry.value + ) { + result[entry.key] = shortcut + } + } + } + + static func contextualShortcut( + for itemCommand: String, + contextualCommand: String?, + shortcuts: [String: DesktopNativeShortcut] + ) -> DesktopNativeShortcut? { + guard itemCommand == (contextualCommand == "new-chat" ? "new-chat" : "new-file") + else { return shortcuts[itemCommand] } + return shortcuts["contextual-new"] ?? shortcuts[itemCommand] + } + + private static func shortcut( + _ command: String, + _ key: String, + _ modifiers: NSEvent.ModifierFlags + ) -> DesktopNativeShortcut { + DesktopNativeShortcut(command: command, key: key, modifiers: modifiers) + } +} diff --git a/platforms/macos/shellular/EmbeddedProxyServer.swift b/platforms/macos/shellular/EmbeddedProxyServer.swift index ebc0010..6d60a4a 100644 --- a/platforms/macos/shellular/EmbeddedProxyServer.swift +++ b/platforms/macos/shellular/EmbeddedProxyServer.swift @@ -3,6 +3,24 @@ import Network import WebKit final class EmbeddedProxyServer { + private static let tunnelFrameBytes = 64 * 1024 + + private final class RawTunnel { + let id: String + let port: Int + let connection: NWConnection + weak var webView: WKWebView? + var sendCredit = 0 + var reading = false + var closed = false + + init(id: String, port: Int, connection: NWConnection, webView: WKWebView) { + self.id = id + self.port = port + self.connection = connection + self.webView = webView + } + } struct PendingConnection { let connection: NWConnection @@ -14,6 +32,9 @@ final class EmbeddedProxyServer { private let queue = DispatchQueue(label: "shellular.embeddedProxy", qos: .userInitiated) private var listeners: [Int: NWListener] = [:] + private var tunnelListeners: [String: NWListener] = [:] + private var tunnelTargets: [Int: String] = [:] + private var rawTunnels: [String: RawTunnel] = [:] private(set) var activePorts: Set = [] private var pendingConnections: [String: PendingConnection] = [:] @@ -73,6 +94,16 @@ final class EmbeddedProxyServer { listener.cancel() } listeners.removeAll() + for (_, listener) in tunnelListeners { + listener.cancel() + } + tunnelListeners.removeAll() + tunnelTargets.removeAll() + for (_, tunnel) in rawTunnels { + tunnel.closed = true + tunnel.connection.cancel() + } + rawTunnels.removeAll() activePorts.removeAll() } @@ -80,6 +111,124 @@ final class EmbeddedProxyServer { return activePorts.contains(port) } + func startTunnelServer(port: Int, targetHost: String, webView: WKWebView) -> Bool { + if tunnelTargets[port] == targetHost { return true } + guard !activePorts.contains(port) else { return false } + guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false } + + var started = false + for bindHost in ["127.0.0.1", "::1"] { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + params.requiredLocalEndpoint = .hostPort( + host: NWEndpoint.Host(bindHost), + port: nwPort + ) + guard let listener = try? NWListener(using: params) else { continue } + let key = "\(bindHost):\(port)" + listener.newConnectionHandler = { [weak self, weak webView] connection in + guard let self, let webView else { + connection.cancel() + return + } + self.handleRawTunnelConnection( + connection, + targetHost: targetHost, + port: port, + webView: webView + ) + } + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + if case .failed = state { + self.tunnelListeners.removeValue(forKey: key) + if !self.tunnelListeners.keys.contains(where: { $0.hasSuffix(":\(port)") }) { + self.tunnelTargets.removeValue(forKey: port) + self.activePorts.remove(port) + } + } + } + listener.start(queue: queue) + tunnelListeners[key] = listener + started = true + } + guard started else { return false } + tunnelTargets[port] = targetHost + activePorts.insert(port) + return true + } + + func stopTunnelServer(port: Int) { + let suffix = ":\(port)" + for key in tunnelListeners.keys.filter({ $0.hasSuffix(suffix) }) { + tunnelListeners.removeValue(forKey: key)?.cancel() + } + tunnelTargets.removeValue(forKey: port) + for tunnel in rawTunnels.values.filter({ $0.port == port }) { + closeRawTunnel(tunnel, notifyJavaScript: true) + } + if listeners[port] == nil { + activePorts.remove(port) + } + } + + func tunnelOpened(id: String, sendCredit: Int) { + queue.async { [weak self] in + guard let self, let tunnel = self.rawTunnels[id], !tunnel.closed else { return } + tunnel.sendCredit = min(1024 * 1024, max(0, sendCredit)) + self.receiveRawTunnelData(tunnel) + } + } + + func addTunnelWindow(id: String, bytes: Int) { + queue.async { [weak self] in + guard let self, let tunnel = self.rawTunnels[id], !tunnel.closed else { return } + tunnel.sendCredit = min(1024 * 1024, tunnel.sendCredit + max(0, bytes)) + self.receiveRawTunnelData(tunnel) + } + } + + func sendTunnelData(id: String, data: Data) { + queue.async { [weak self] in + guard let self, let tunnel = self.rawTunnels[id], !tunnel.closed else { return } + tunnel.connection.send(content: data, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.closeRawTunnel(tunnel, notifyJavaScript: true, error: error.localizedDescription) + return + } + self.callProxy( + tunnel.webView, + function: "tcpConsumed", + arguments: ["id": id, "bytes": data.count] + ) + }) + } + } + + func endTunnel(id: String) { + queue.async { [weak self] in + guard let self, let tunnel = self.rawTunnels[id], !tunnel.closed else { return } + tunnel.connection.send( + content: nil, + contentContext: .finalMessage, + isComplete: true, + completion: .contentProcessed { error in + if let error { + self.closeRawTunnel(tunnel, notifyJavaScript: true, error: error.localizedDescription) + } + } + ) + } + } + + func closeTunnel(id: String, error: String? = nil) { + queue.async { [weak self] in + guard let self, let tunnel = self.rawTunnels[id] else { return } + self.closeRawTunnel(tunnel, notifyJavaScript: false, error: error) + } + } + func cancelAllPending() { queue.async { [weak self] in guard let self else { return } @@ -196,11 +345,131 @@ final class EmbeddedProxyServer { readHTTPRequest(connection, webView: webView, accumulatedData: Data()) } + private func handleRawTunnelConnection( + _ connection: NWConnection, + targetHost: String, + port: Int, + webView: WKWebView + ) { + let id = "tcp_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + let tunnel = RawTunnel( + id: id, + port: port, + connection: connection, + webView: webView + ) + rawTunnels[id] = tunnel + connection.stateUpdateHandler = { [weak self, weak tunnel] state in + guard let self, let tunnel else { return } + switch state { + case .failed(let error): + self.closeRawTunnel(tunnel, notifyJavaScript: true, error: error.localizedDescription) + case .cancelled: + self.closeRawTunnel(tunnel, notifyJavaScript: true) + default: + break + } + } + connection.start(queue: queue) + callProxy( + webView, + function: "tcpOpen", + arguments: ["id": id, "host": targetHost, "port": port] + ) + } + + private func receiveRawTunnelData(_ tunnel: RawTunnel) { + guard !tunnel.closed, !tunnel.reading, tunnel.sendCredit > 0 else { return } + tunnel.reading = true + let maximum = min(Self.tunnelFrameBytes, tunnel.sendCredit) + tunnel.connection.receive( + minimumIncompleteLength: 1, + maximumLength: maximum + ) { [weak self, weak tunnel] data, _, isComplete, error in + guard let self, let tunnel else { return } + tunnel.reading = false + if let error { + self.closeRawTunnel(tunnel, notifyJavaScript: true, error: error.localizedDescription) + return + } + if let data, !data.isEmpty { + tunnel.sendCredit -= data.count + self.callProxy( + tunnel.webView, + function: "tcpData", + arguments: ["id": tunnel.id, "chunk": data.base64EncodedString()] + ) + } + if isComplete { + self.callProxy( + tunnel.webView, + function: "tcpEnd", + arguments: ["id": tunnel.id] + ) + return + } + self.receiveRawTunnelData(tunnel) + } + } + + private func closeRawTunnel( + _ tunnel: RawTunnel, + notifyJavaScript: Bool, + error: String? = nil + ) { + guard !tunnel.closed else { return } + tunnel.closed = true + rawTunnels.removeValue(forKey: tunnel.id) + tunnel.connection.cancel() + if notifyJavaScript { + callProxy( + tunnel.webView, + function: "tcpClose", + arguments: ["id": tunnel.id] + ) + } + if let error, !error.isEmpty { + print("[EmbeddedProxy] TCP tunnel \(tunnel.id) closed: \(error)") + } + } + + private func callProxy( + _ webView: WKWebView?, + function: String, + arguments: [String: Any] + ) { + guard let webView else { return } + DispatchQueue.main.async { + webView.callAsyncJavaScript( + "window.__shellularProxy && window.__shellularProxy[function](...args)", + arguments: [ + "function": function, + "args": Self.proxyArguments(function: function, values: arguments), + ], + in: nil, + in: .page + ) { _ in } + } + } + + private static func proxyArguments(function: String, values: [String: Any]) -> [Any] { + switch function { + case "tcpOpen": + return [values["id"] ?? "", values["host"] ?? "localhost", values["port"] ?? 0] + case "tcpData": + return [values["id"] ?? "", values["chunk"] ?? ""] + case "tcpConsumed": + return [values["id"] ?? "", values["bytes"] ?? 0] + default: + return [values["id"] ?? ""] + } + } + private func readHTTPRequest(_ connection: NWConnection, webView: WKWebView, accumulatedData: Data) { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in guard let self else { return } - if let error = error { + if error != nil { connection.cancel() return } diff --git a/platforms/macos/shellular/EmbeddedProxyService.swift b/platforms/macos/shellular/EmbeddedProxyService.swift index 6ac90c5..d85d69d 100644 --- a/platforms/macos/shellular/EmbeddedProxyService.swift +++ b/platforms/macos/shellular/EmbeddedProxyService.swift @@ -1,3 +1,5 @@ +import Foundation + final class EmbeddedProxyService: BaseService { private let server = EmbeddedProxyServer.shared @@ -12,6 +14,40 @@ final class EmbeddedProxyService: BaseService { handleResponseEnd(args: args, callback: callback) case "responseError": handleResponseError(args: args, callback: callback) + case "tunnelOpened": + guard let id = args[safe: 0] as? String, + let sendCredit = args[safe: 2] as? Int else { + return callback.error("Invalid tunnelOpened args") + } + server.tunnelOpened(id: id, sendCredit: sendCredit) + callback.success() + case "tunnelWindow": + guard let id = args[safe: 0] as? String, + let bytes = args[safe: 1] as? Int else { + return callback.error("Invalid tunnelWindow args") + } + server.addTunnelWindow(id: id, bytes: bytes) + callback.success() + case "tunnelData": + guard let id = args[safe: 0] as? String, + let value = args[safe: 1] as? String, + let data = Data(base64Encoded: value) else { + return callback.error("Invalid tunnelData args") + } + server.sendTunnelData(id: id, data: data) + callback.success() + case "tunnelEnd": + guard let id = args[safe: 0] as? String else { + return callback.error("Invalid tunnelEnd args") + } + server.endTunnel(id: id) + callback.success() + case "tunnelClosed": + guard let id = args[safe: 0] as? String else { + return callback.error("Invalid tunnelClosed args") + } + server.closeTunnel(id: id, error: args[safe: 1] as? String) + callback.success() default: callback.error("Unknown action: \(action)") } diff --git a/platforms/macos/shellular/Info.plist b/platforms/macos/shellular/Info.plist index 68d0e63..e9f076f 100644 --- a/platforms/macos/shellular/Info.plist +++ b/platforms/macos/shellular/Info.plist @@ -38,5 +38,7 @@ Shellular uses the microphone when requested by connected tools. NSPrincipalClass NSApplication + ShellularDevCLIDirectory + $(SHELLULAR_DEV_CLI_DIRECTORY) diff --git a/platforms/macos/shellular/LocalCLIActivationClient.swift b/platforms/macos/shellular/LocalCLIActivationClient.swift new file mode 100644 index 0000000..b84df1a --- /dev/null +++ b/platforms/macos/shellular/LocalCLIActivationClient.swift @@ -0,0 +1,512 @@ +import Darwin +import Foundation + +let localCLIActivationProtocolVersion = 1 +let localCLIRequiredProtocolVersion = 1 +let localCLIActivationMaxMessageBytes = 16 * 1024 + +struct LocalCLIActivationLock: Decodable, Equatable { + let pid: Int + let instanceId: String? + let cliVersion: String? + let activationProtocolVersion: Int? + let activationEndpoint: String? + let activationSecret: String? + let activationReady: Bool? +} + +struct LocalCLIActivationResult: Decodable, Equatable { + let state: String + let pid: Int + let port: Int + let cliVersion: String + let localProtocolVersion: Int +} + +struct LocalCLIActivationError: LocalizedError, Equatable { + let code: String + let message: String + let currentVersion: String? + + init(code: String, message: String, currentVersion: String? = nil) { + self.code = code + self.message = message + self.currentVersion = currentVersion + } + + static func unsupported(currentVersion: String?) -> LocalCLIActivationError { + LocalCLIActivationError( + code: "EXISTING_CLI_UNSUPPORTED", + message: "The running Shellular CLI must be updated and restarted once to enable local access.", + currentVersion: currentVersion + ) + } + + var errorDescription: String? { + if code == "EXISTING_CLI_UNSUPPORTED" { + let version = currentVersion.map { " (running v\($0))" } ?? "" + return "CLI update required\(version). Restart the updated CLI once to enable local access." + } + return message + } +} + +struct LocalCLIActivationLockLoader { + struct Config: Decodable { let machineId: String } + + let homeDirectory: URL + let temporaryDirectory: URL + let username: String + let readData: (URL) throws -> Data + let pauseBetweenReads: () -> Void + + init( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + temporaryDirectory: URL = FileManager.default.temporaryDirectory, + username: String = NSUserName(), + readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) }, + pauseBetweenReads: @escaping () -> Void = { Thread.sleep(forTimeInterval: 0.01) } + ) { + self.homeDirectory = homeDirectory + self.temporaryDirectory = temporaryDirectory + self.username = username + self.readData = readData + self.pauseBetweenReads = pauseBetweenReads + } + + func load() throws -> LocalCLIActivationLock? { + let configURL = homeDirectory.appendingPathComponent(".shellular/config.json") + let configData: Data + do { + configData = try readData(configURL) + } catch { + if Self.isFileNotFound(error) { return nil } + throw Self.invalidLock("Could not read the Shellular CLI configuration.") + } + + let config: Config + do { + config = try JSONDecoder().decode(Config.self, from: configData) + } catch { + throw Self.invalidLock("The Shellular CLI configuration is malformed.") + } + guard Self.isSafeFilenameComponent(config.machineId), Self.isSafeFilenameComponent(username) else { + throw Self.invalidLock("The Shellular CLI identity is invalid.") + } + + let lockURL = temporaryDirectory + .appendingPathComponent("shellular-\(config.machineId)-\(username).lock") + for attempt in 0..<3 { + do { + let data = try readData(lockURL) + return try JSONDecoder().decode(LocalCLIActivationLock.self, from: data) + } catch { + if Self.isFileNotFound(error) { return nil } + if attempt < 2 { + pauseBetweenReads() + continue + } + throw Self.invalidLock("The running Shellular CLI state is malformed.") + } + } + return nil + } + + private static func invalidLock(_ message: String) -> LocalCLIActivationError { + LocalCLIActivationError(code: "INVALID_CLI_STATE", message: message) + } + + private static func isSafeFilenameComponent(_ value: String) -> Bool { + !value.isEmpty && value != "." && value != ".." && + !value.contains("/") && !value.contains("\0") + } + + private static func isFileNotFound(_ error: Error) -> Bool { + let nsError = error as NSError + return (nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileNoSuchFileError) || + (nsError.domain == NSPOSIXErrorDomain && nsError.code == Int(ENOENT)) + } +} + +enum LocalCLIUnixSocketTransportError: LocalizedError, Equatable { + case invalidEndpoint + case unavailable(Int32) + case timedOut + case closed + case responseTooLarge + + var retryable: Bool { + switch self { + case .unavailable, .timedOut, .closed: true + case .invalidEndpoint, .responseTooLarge: false + } + } + + var errorDescription: String? { + switch self { + case .invalidEndpoint: "The CLI activation socket path is invalid." + case .unavailable(let code): "The CLI activation socket is unavailable (\(code))." + case .timedOut: "Timed out waiting for the CLI activation socket." + case .closed: "The CLI activation socket closed without a response." + case .responseTooLarge: "The CLI activation response is too large." + } + } +} + +struct LocalCLIUnixSocketTransport { + func exchange(endpoint: String, request: Data, timeout: TimeInterval) throws -> Data { + let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw LocalCLIUnixSocketTransportError.unavailable(errno) + } + defer { Darwin.close(descriptor) } + + var noSignal: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSignal, + socklen_t(MemoryLayout.size(ofValue: noSignal)) + ) == 0 else { + throw LocalCLIUnixSocketTransportError.unavailable(errno) + } + + var timeoutValue = timeval( + tv_sec: Int(timeout), + tv_usec: Int32((timeout - floor(timeout)) * 1_000_000) + ) + let timeoutSize = socklen_t(MemoryLayout.size(ofValue: timeoutValue)) + guard withUnsafePointer(to: &timeoutValue, { + setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, $0, timeoutSize) + }) == 0 else { + throw LocalCLIUnixSocketTransportError.unavailable(errno) + } + guard withUnsafePointer(to: &timeoutValue, { + setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, $0, timeoutSize) + }) == 0 else { + throw LocalCLIUnixSocketTransportError.unavailable(errno) + } + + var address = sockaddr_un() + let path = Array(endpoint.utf8CString) + let pathOffset = MemoryLayout.offset(of: \.sun_path) ?? 0 + guard !endpoint.isEmpty, path.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw LocalCLIUnixSocketTransportError.invalidEndpoint + } + let addressLength = pathOffset + path.count + guard addressLength <= Int(UInt8.max) else { + throw LocalCLIUnixSocketTransportError.invalidEndpoint + } + address.sun_family = sa_family_t(AF_UNIX) + address.sun_len = UInt8(addressLength) + withUnsafeMutablePointer(to: &address.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: path.count) { destination in + for index in path.indices { + destination[index] = path[index] + } + } + } + + let connectResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(descriptor, $0, socklen_t(addressLength)) + } + } + guard connectResult == 0 else { + throw LocalCLIUnixSocketTransportError.unavailable(errno) + } + + try request.withUnsafeBytes { bytes in + guard let baseAddress = bytes.baseAddress else { return } + var sent = 0 + while sent < bytes.count { + let count = Darwin.send( + descriptor, + baseAddress.advanced(by: sent), + bytes.count - sent, + 0 + ) + if count > 0 { + sent += count + } else if count < 0 && errno == EINTR { + continue + } else if errno == EAGAIN || errno == EWOULDBLOCK { + throw LocalCLIUnixSocketTransportError.timedOut + } else { + throw LocalCLIUnixSocketTransportError.unavailable(errno) + } + } + } + + var response = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while response.count <= localCLIActivationMaxMessageBytes { + let count = buffer.withUnsafeMutableBytes { + Darwin.recv(descriptor, $0.baseAddress, $0.count, 0) + } + if count > 0 { + response.append(buffer, count: count) + if let newline = response.firstIndex(of: 0x0A) { + return Data(response.prefix(through: newline)) + } + } else if count == 0 { + throw LocalCLIUnixSocketTransportError.closed + } else if errno == EINTR { + continue + } else if errno == EAGAIN || errno == EWOULDBLOCK { + throw LocalCLIUnixSocketTransportError.timedOut + } else { + throw LocalCLIUnixSocketTransportError.unavailable(errno) + } + } + throw LocalCLIUnixSocketTransportError.responseTooLarge + } +} + +final class LocalCLIActivationClient { + typealias LockProvider = () throws -> LocalCLIActivationLock? + typealias ProcessChecker = (Int) -> Bool + typealias Exchange = (String, Data, TimeInterval) throws -> Data + + private struct Request: Encodable { + let type = "ACTIVATE_LOCAL" + let protocolVersion = localCLIActivationProtocolVersion + let requestId: String + let targetInstanceId: String + let secret: String + let requiredLocalProtocolVersion = localCLIRequiredProtocolVersion + let port: Int + let token: String + let source = "attached" + let ownerId: String + } + + private struct Response: Decodable { + struct RemoteError: Decodable { + let code: String + let message: String + let currentVersion: String? + } + + let requestId: String + let ok: Bool + let state: String? + let pid: Int? + let port: Int? + let cliVersion: String? + let localProtocolVersion: Int? + let error: RemoteError? + } + + private let lockProvider: LockProvider + private let processChecker: ProcessChecker + private let exchange: Exchange + private let endpointValidator: (String) -> Bool + private let now: () -> Date + private let sleep: (TimeInterval) -> Void + private let requestId: () -> String + private let activationTimeout: TimeInterval + private let attemptTimeout: TimeInterval + private let retryInterval: TimeInterval + + init( + lockProvider: @escaping LockProvider = { try LocalCLIActivationLockLoader().load() }, + processChecker: @escaping ProcessChecker = LocalCLIActivationClient.defaultProcessChecker, + exchange: @escaping Exchange = LocalCLIUnixSocketTransport().exchange, + endpointValidator: @escaping (String) -> Bool = LocalCLIActivationClient.defaultEndpointValidator, + now: @escaping () -> Date = Date.init, + sleep: @escaping (TimeInterval) -> Void = Thread.sleep(forTimeInterval:), + requestId: @escaping () -> String = { UUID().uuidString }, + activationTimeout: TimeInterval = 15, + attemptTimeout: TimeInterval = 2, + retryInterval: TimeInterval = 0.1 + ) { + self.lockProvider = lockProvider + self.processChecker = processChecker + self.exchange = exchange + self.endpointValidator = endpointValidator + self.now = now + self.sleep = sleep + self.requestId = requestId + self.activationTimeout = activationTimeout + self.attemptTimeout = attemptTimeout + self.retryInterval = retryInterval + } + + /// Returns nil only when no live CLI owns the boot lock. A live but + /// incompatible CLI is an error so the caller never starts a competing CLI. + func activate(token: String, ownerId: String, port: Int) throws -> LocalCLIActivationResult? { + guard token.utf8.count >= 16, (0...65535).contains(port) else { + throw LocalCLIActivationError( + code: "START_FAILED", + message: "The local CLI activation parameters are invalid." + ) + } + guard var lock = try lockProvider() else { return nil } + guard processChecker(lock.pid) else { return nil } + + let deadline = now().addingTimeInterval(activationTimeout) + var lastTransportError: Error? + while now() < deadline { + try validate(lock) + if lock.activationReady == true, + let endpoint = lock.activationEndpoint, + let instanceId = lock.instanceId, + let secret = lock.activationSecret + { + let id = requestId() + let data = try Self.makeRequestData( + requestId: id, + instanceId: instanceId, + secret: secret, + token: token, + ownerId: ownerId, + port: port + ) + do { + let response = try exchange(endpoint, data, attemptTimeout) + return try Self.parseResponse(response, expectedRequestId: id) + } catch let error as LocalCLIUnixSocketTransportError { + guard error.retryable else { + throw LocalCLIActivationError( + code: "START_FAILED", + message: error.localizedDescription + ) + } + lastTransportError = error + } + } + + sleep(retryInterval) + guard let refreshed = try lockProvider() else { + throw LocalCLIActivationError( + code: "START_FAILED", + message: "The running Shellular CLI exited during activation." + ) + } + guard processChecker(refreshed.pid) else { + throw LocalCLIActivationError( + code: "START_FAILED", + message: "The running Shellular CLI exited during activation." + ) + } + lock = refreshed + } + + throw LocalCLIActivationError( + code: "ACTIVATION_TIMEOUT", + message: lastTransportError?.localizedDescription ?? "Timed out enabling local access." + ) + } + + static func makeRequestData( + requestId: String, + instanceId: String, + secret: String, + token: String, + ownerId: String, + port: Int + ) throws -> Data { + var data = try JSONEncoder().encode(Request( + requestId: requestId, + targetInstanceId: instanceId, + secret: secret, + port: port, + token: token, + ownerId: ownerId + )) + data.append(0x0A) + return data + } + + static func parseResponse( + _ data: Data, + expectedRequestId: String + ) throws -> LocalCLIActivationResult { + guard data.count <= localCLIActivationMaxMessageBytes, + let newline = data.firstIndex(of: 0x0A) + else { + throw LocalCLIActivationError( + code: "INVALID_RESPONSE", + message: "The CLI activation response is malformed." + ) + } + let response: Response + do { + response = try JSONDecoder().decode(Response.self, from: data.prefix(upTo: newline)) + } catch { + throw LocalCLIActivationError( + code: "INVALID_RESPONSE", + message: "The CLI activation response is malformed." + ) + } + guard response.requestId == expectedRequestId else { + throw LocalCLIActivationError( + code: "INVALID_RESPONSE", + message: "The CLI activation response did not match the request." + ) + } + if !response.ok { + guard let error = response.error else { + throw LocalCLIActivationError( + code: "INVALID_RESPONSE", + message: "The CLI activation error response is malformed." + ) + } + throw LocalCLIActivationError( + code: error.code, + message: error.message, + currentVersion: error.currentVersion + ) + } + guard let state = response.state, + state == "started" || state == "already-running", + let pid = response.pid, + let port = response.port, + (1...65535).contains(port), + let cliVersion = response.cliVersion, + let localProtocolVersion = response.localProtocolVersion + else { + throw LocalCLIActivationError( + code: "INVALID_RESPONSE", + message: "The CLI activation success response is malformed." + ) + } + guard localProtocolVersion == localCLIRequiredProtocolVersion else { + throw LocalCLIActivationError.unsupported(currentVersion: cliVersion) + } + return LocalCLIActivationResult( + state: state, + pid: pid, + port: port, + cliVersion: cliVersion, + localProtocolVersion: localProtocolVersion + ) + } + + private func validate(_ lock: LocalCLIActivationLock) throws { + guard lock.activationProtocolVersion == localCLIActivationProtocolVersion, + let instanceId = lock.instanceId, !instanceId.isEmpty, + let endpoint = lock.activationEndpoint, endpointValidator(endpoint), + let secret = lock.activationSecret, secret.utf8.count >= 32 + else { + throw LocalCLIActivationError.unsupported(currentVersion: lock.cliVersion) + } + } + + private static func defaultProcessChecker(_ pid: Int) -> Bool { + guard pid > 0, pid <= Int(Int32.max) else { return false } + return Darwin.kill(pid_t(pid), 0) == 0 || errno == EPERM + } + + private static func defaultEndpointValidator(_ endpoint: String) -> Bool { + guard !endpoint.isEmpty else { return false } + let temporaryDirectory = FileManager.default.temporaryDirectory + .standardizedFileURL.path + let parent = URL(fileURLWithPath: endpoint) + .deletingLastPathComponent() + .standardizedFileURL.path + return parent == temporaryDirectory + } +} diff --git a/platforms/macos/shellular/LocalCLIService.swift b/platforms/macos/shellular/LocalCLIService.swift new file mode 100644 index 0000000..7ded2fc --- /dev/null +++ b/platforms/macos/shellular/LocalCLIService.swift @@ -0,0 +1,305 @@ +import Foundation +import Security +import CoreImage +import AppKit + +final class LocalCLIService: BaseService { + override func exec(action: String, args: [Any], callback: Callback) { + switch action { + case "capability": callback.success(LocalCLIManager.shared.capability()) + case "ensureRunning": LocalCLIManager.shared.ensureRunning { result in self.finish(result, callback) } + case "status": LocalCLIManager.shared.request(path: "/control/status", method: "GET", body: nil) { self.finish($0, callback) } + case "ticket": LocalCLIManager.shared.request(path: "/control/ticket", method: "POST", body: args.first) { self.finish($0, callback) } + case "mutateClient": LocalCLIManager.shared.request(path: "/control/clients", method: "POST", body: args.first) { self.finish($0, callback) } + case "stop": LocalCLIManager.shared.request(path: "/control/stop", method: "POST", body: [:]) { self.finish($0, callback) } + case "disable": LocalCLIManager.shared.request(path: "/control/disable", method: "POST", body: [:]) { self.finish($0, callback) } + case "qrCode": + guard let text = args.first as? String, let data = text.data(using: .utf8), let filter = CIFilter(name: "CIQRCodeGenerator") else { return callback.error("QR data required") } + filter.setValue(data, forKey: "inputMessage"); filter.setValue("M", forKey: "inputCorrectionLevel") + guard let image = filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 7, y: 7)), + let cgImage = CIContext().createCGImage(image, from: image.extent) else { return callback.error("Could not create QR code") } + let bitmap = NSBitmapImageRep(cgImage: cgImage) + guard let png = bitmap.representation(using: NSBitmapImageRep.FileType.png, properties: [:]) else { return callback.error("Could not encode QR code") } + callback.success("data:image/png;base64,\(png.base64EncodedString())") + default: callback.error("Unknown action: \(action)") + } + } + private func finish(_ result: Result, _ callback: Callback) { + switch result { case .success(let value): callback.success(value); case .failure(let error): callback.error(error.localizedDescription) } + } +} + +final class LocalCLIManager { + static let shared = LocalCLIManager() + private let protocolVersion = 1 + private let devPort = 51238 + private let token = UUID().uuidString + UUID().uuidString + private let ownerId = UUID().uuidString + private let activationClient = LocalCLIActivationClient() + private let ensureQueue = DispatchQueue(label: "io.foxbiz.shellular.local-cli.ensure") + private var ensureCallbacks: [(Result) -> Void] = [] + private var port: Int? + private var process: Process? + private var didTryGlobalInstall = false + + private init() {} + + func capability() -> [String: Any] { + let sandboxed: Bool + if let task = SecTaskCreateFromSelf(nil), + let value = SecTaskCopyValueForEntitlement(task, "com.apple.security.app-sandbox" as CFString, nil) { + sandboxed = (value as? Bool) == true + } else { + sandboxed = false + } + return ["available": !sandboxed, "sandboxed": sandboxed, "protocolVersion": protocolVersion] + } + + func ensureRunning(completion: @escaping (Result) -> Void) { + ensureQueue.async { + self.ensureCallbacks.append(completion) + guard self.ensureCallbacks.count == 1 else { return } + self.ensureRunningOnce { result in + self.ensureQueue.async { + let callbacks = self.ensureCallbacks + self.ensureCallbacks.removeAll() + callbacks.forEach { $0(result) } + } + } + } + } + + private func ensureRunningOnce(completion: @escaping (Result) -> Void) { + guard (capability()["available"] as? Bool) == true else { return completion(.failure(LocalCLIError("Local access is unavailable in this build."))) } + if let discovery = readDiscovery() { + guard discovery.protocolVersion == protocolVersion else { + return completion(.failure(LocalCLIError("CLI update required (running v\(discovery.cliVersion)). Restart the updated CLI once to enable local access."))) + } + port = discovery.port + return request(path: "/control/status", method: "GET", body: nil) { result in + switch result { + case .success: completion(result) + case .failure: self.activateRunningOrStart(preferredPort: self.devPort, completion: completion) + } + } + } + activateRunningOrStart(preferredPort: devPort, completion: completion) + } + + private func activateRunningOrStart(preferredPort: Int, completion: @escaping (Result) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + #if DEBUG + let activationPort = preferredPort + #else + let activationPort = 0 + #endif + do { + let activation: LocalCLIActivationResult? + do { + activation = try self.activationClient.activate( + token: self.token, + ownerId: self.ownerId, + port: activationPort + ) + } catch let error as LocalCLIActivationError + where error.code == "PORT_IN_USE" && activationPort != 0 + { + activation = try self.activationClient.activate( + token: self.token, + ownerId: self.ownerId, + port: 0 + ) + } + guard let activation else { + return self.startLocal(preferredPort: preferredPort, completion: completion) + } + self.port = activation.port + self.request(path: "/control/status", method: "GET", body: nil) { result in + switch result { + case .success: completion(result) + case .failure(let error): + completion(.failure(LocalCLIError( + "The running CLI enabled local access, but the app could not connect: \(error.localizedDescription)" + ))) + } + } + } catch { + completion(.failure(error)) + } + } + } + + private func startLocal(preferredPort: Int, completion: @escaping (Result) -> Void) { + #if DEBUG + guard let directory = Bundle.main.object(forInfoDictionaryKey: "ShellularDevCLIDirectory") as? String, + !directory.isEmpty, FileManager.default.fileExists(atPath: directory) else { + return completion(.failure(LocalCLIError("The workspace CLI could not be found for this development build."))) + } + start( + command: "pnpm", + arguments: [ + "run", "activate:local", + "--port", String(preferredPort), + "--activate-existing-only", + "--wait-for-existing", "45000", + ], + directory: directory, + retryDynamicPort: preferredPort != 0, + completion: completion + ) + #else + start(command: "npx", arguments: ["--yes", "shellular@latest", "--start-local", "--port", "0", "--no-qr"], completion: completion) + #endif + } + + func request(path: String, method: String, body: Any?, completion: @escaping (Result) -> Void) { + guard let port else { return completion(.failure(LocalCLIError("Local CLI is not running"))) } + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)\(path)")!) + request.httpMethod = method + request.timeoutInterval = 4 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let body { request.httpBody = try? JSONSerialization.data(withJSONObject: body) } + URLSession.shared.dataTask(with: request) { data, response, error in + if let error { return completion(.failure(error)) } + guard let http = response as? HTTPURLResponse, let data else { return completion(.failure(LocalCLIError("Invalid local CLI response"))) } + let value = (try? JSONSerialization.jsonObject(with: data)) ?? [:] + guard (200..<300).contains(http.statusCode) else { return completion(.failure(LocalCLIError(Self.errorMessage(value) ?? "Local CLI request failed (\(http.statusCode))"))) } + completion(.success(value)) + }.resume() + } + + private func start(command: String, arguments: [String], directory: String? = nil, retryDynamicPort: Bool = false, completion: @escaping (Result) -> Void) { + let child = Process() + child.executableURL = URL(fileURLWithPath: "/usr/bin/env") + child.arguments = [command] + arguments + if let directory { child.currentDirectoryURL = URL(fileURLWithPath: directory) } + var environment = ProcessInfo.processInfo.environment + environment["SHELLULAR_LOCAL_TOKEN"] = token + environment["SHELLULAR_LOCAL_OWNER_ID"] = ownerId + #if DEBUG + environment["SHELLULAR_LOCAL_SOURCE"] = "development" + #else + environment["SHELLULAR_LOCAL_SOURCE"] = command == "shellular" ? "global" : "npx" + #endif + environment["PATH"] = Self.commandSearchPath(existing: environment["PATH"]) + child.environment = environment + child.standardInput = FileHandle.nullDevice + child.standardOutput = FileHandle.nullDevice + let errorPipe = Pipe(); child.standardError = errorPipe + do { try child.run(); process = child } catch { return completion(.failure(error)) } + pollForDiscovery(attempt: 0, errorPipe: errorPipe, retryDynamicPort: retryDynamicPort, completion: completion) + } + + private func pollForDiscovery(attempt: Int, errorPipe: Pipe, retryDynamicPort: Bool = false, completion: @escaping (Result) -> Void) { + let childExited = process?.isRunning == false + if let discovery = readDiscovery(), discovery.protocolVersion == protocolVersion { + port = discovery.port + return request(path: "/control/status", method: "GET", body: nil) { result in + switch result { + case .success: completion(result) + case .failure: + if attempt >= 240 || childExited { + self.finishStartFailure(errorPipe: errorPipe, retryDynamicPort: retryDynamicPort, completion: completion) + } else { + DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) { + self.pollForDiscovery(attempt: attempt + 1, errorPipe: errorPipe, retryDynamicPort: retryDynamicPort, completion: completion) + } + } + } + } + } + if attempt >= 240 || childExited { + if attempt >= 240, process?.isRunning == true { process?.terminate(); process?.waitUntilExit() } + return finishStartFailure(errorPipe: errorPipe, retryDynamicPort: retryDynamicPort, completion: completion) + } + DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) { self.pollForDiscovery(attempt: attempt + 1, errorPipe: errorPipe, retryDynamicPort: retryDynamicPort, completion: completion) } + } + + private func finishStartFailure(errorPipe: Pipe, retryDynamicPort: Bool, completion: @escaping (Result) -> Void) { + let stderr = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + if retryDynamicPort, Self.structuredError(stderr)?.code == "PORT_IN_USE" { + return startLocal(preferredPort: 0, completion: completion) + } + #if !DEBUG + let exit127 = process.map { !$0.isRunning && $0.terminationStatus == 127 } ?? false + let resolutionFailure = stderr.localizedCaseInsensitiveContains("not found") || stderr.localizedCaseInsensitiveContains("could not determine executable") || exit127 + if resolutionFailure && !didTryGlobalInstall { + didTryGlobalInstall = true + return installGlobalAndStart(completion: completion) + } + #endif + if let error = Self.structuredError(stderr) { + let message: String + if error.code == "EXISTING_CLI_UNSUPPORTED" { + message = "CLI update required\(error.currentVersion.map { " (running v\($0))" } ?? ""). Restart the updated CLI once to enable local access." + } else if error.code == "CLI_NOT_RUNNING" { + message = "Waiting for the development workspace CLI. Start the normal CLI development command and the app will connect automatically." + } else { + message = error.message + } + return completion(.failure(LocalCLIError(message))) + } + completion(.failure(LocalCLIError(stderr.isEmpty ? "Local access could not be prepared." : stderr))) + } + + private func installGlobalAndStart(completion: @escaping (Result) -> Void) { + DispatchQueue.global().async { + let installer = Process(); installer.executableURL = URL(fileURLWithPath: "/usr/bin/env") + installer.arguments = ["npm", "install", "--global", "shellular@latest"] + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = Self.commandSearchPath(existing: environment["PATH"]) + installer.environment = environment + let pipe = Pipe(); installer.standardError = pipe; installer.standardOutput = FileHandle.nullDevice + do { try installer.run(); installer.waitUntilExit() } catch { return completion(.failure(error)) } + guard installer.terminationStatus == 0 else { + let message = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "npm install failed" + return completion(.failure(LocalCLIError(message))) + } + self.start(command: "shellular", arguments: ["--start-local", "--port", "0", "--no-qr"], completion: completion) + } + } + + private struct Discovery: Decodable { let pid: Int; let port: Int; let cliVersion: String; let protocolVersion: Int } + private struct StructuredCLIError: Decodable { + let code: String + let message: String + let currentVersion: String? + } + private func readDiscovery() -> Discovery? { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".shellular/local-control.json") + guard let data = try? Data(contentsOf: url), let value = try? JSONDecoder().decode(Discovery.self, from: data), kill(pid_t(value.pid), 0) == 0 else { return nil } + return value + } + + private static func errorMessage(_ value: Any) -> String? { ((value as? [String: Any])?["error"] as? [String: Any])?["message"] as? String } + private static func commandSearchPath(existing: String?) -> String { + let home = FileManager.default.homeDirectoryForCurrentUser + var paths = (existing ?? "").split(separator: ":").map(String.init) + paths += [ + "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", + home.appendingPathComponent(".volta/bin").path, + home.appendingPathComponent(".local/share/pnpm").path, + home.appendingPathComponent("Library/pnpm").path, + ] + let nvmRoot = home.appendingPathComponent(".nvm/versions/node") + if let versions = try? FileManager.default.contentsOfDirectory(at: nvmRoot, includingPropertiesForKeys: nil) { + paths += versions.sorted { $0.lastPathComponent > $1.lastPathComponent }.map { $0.appendingPathComponent("bin").path } + } + return Array(NSOrderedSet(array: paths)) + .compactMap { $0 as? String } + .joined(separator: ":") + } + private static func structuredError(_ text: String) -> StructuredCLIError? { + guard let line = text.split(separator: "\n").last(where: { $0.contains("SHELLULAR_LOCAL_ERROR ") }), + let marker = line.range(of: "SHELLULAR_LOCAL_ERROR ") else { return nil } + let json = String(line[marker.upperBound...]) + return try? JSONDecoder().decode(StructuredCLIError.self, from: Data(json.utf8)) + } +} + +private struct LocalCLIError: LocalizedError { + let text: String + init(_ text: String) { self.text = text } + var errorDescription: String? { text } +} diff --git a/platforms/macos/shellular/NativeThemeColor.swift b/platforms/macos/shellular/NativeThemeColor.swift new file mode 100644 index 0000000..b159886 --- /dev/null +++ b/platforms/macos/shellular/NativeThemeColor.swift @@ -0,0 +1,112 @@ +import Foundation + +enum TitlebarDoubleClickAction: Equatable { + case fill + case minimize + case none + + static func resolve(_ rawValue: String?) -> TitlebarDoubleClickAction { + switch rawValue?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "minimize": + return .minimize + case "none", "do nothing", "donothing": + return .none + case nil, "fill", "maximize", "zoom": + return .fill + default: + return .none + } + } +} + +enum WindowFrameAnimationTiming { + static func easedProgress(elapsed: TimeInterval, duration: TimeInterval) -> Double { + guard duration > 0 else { return 1 } + let progress = min(max(elapsed / duration, 0), 1) + if progress < 0.5 { + return 4 * progress * progress * progress + } + let inverse = -2 * progress + 2 + return 1 - inverse * inverse * inverse / 2 + } +} + +struct NativeThemeColor: Equatable { + let red: Double + let green: Double + let blue: Double + let alpha: Double +} + +enum NativeThemeColorParser { + static func parse(_ rawValue: String) -> NativeThemeColor? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if value.hasPrefix("#") { + return parseHex(String(value.dropFirst())) + } + if value.hasPrefix("rgb(") || value.hasPrefix("rgba(") { + return parseRGB(value) + } + return nil + } + + private static func parseHex(_ value: String) -> NativeThemeColor? { + let expanded: String + switch value.count { + case 3, 4: + expanded = value.map { "\($0)\($0)" }.joined() + case 6, 8: + expanded = value + default: + return nil + } + + guard let number = UInt64(expanded, radix: 16) else { return nil } + let includesAlpha = expanded.count == 8 + let redShift = includesAlpha ? 24 : 16 + let greenShift = includesAlpha ? 16 : 8 + let blueShift = includesAlpha ? 8 : 0 + return NativeThemeColor( + red: Double((number >> redShift) & 0xff) / 255, + green: Double((number >> greenShift) & 0xff) / 255, + blue: Double((number >> blueShift) & 0xff) / 255, + alpha: includesAlpha ? Double(number & 0xff) / 255 : 1 + ) + } + + private static func parseRGB(_ value: String) -> NativeThemeColor? { + guard let open = value.firstIndex(of: "("), let close = value.lastIndex(of: ")"), open < close else { + return nil + } + let components = value[value.index(after: open).. Double? { + if value.hasSuffix("%") { + guard let percentage = Double(value.dropLast()), (0...100).contains(percentage) else { return nil } + return percentage / 100 + } + guard let channel = Double(value), (0...255).contains(channel) else { return nil } + return channel / 255 + } + + private static func alpha(_ value: String) -> Double? { + if value.hasSuffix("%") { + guard let percentage = Double(value.dropLast()), (0...100).contains(percentage) else { return nil } + return percentage / 100 + } + guard let alpha = Double(value), (0...1).contains(alpha) else { return nil } + return alpha + } +} diff --git a/platforms/macos/shellular/PrivateFileStore.swift b/platforms/macos/shellular/PrivateFileStore.swift new file mode 100644 index 0000000..f7ddeb0 --- /dev/null +++ b/platforms/macos/shellular/PrivateFileStore.swift @@ -0,0 +1,220 @@ +import CryptoKit +import Darwin +import Foundation + +enum PrivateFileStoreError: LocalizedError { + case invalidStoredValue + case temporaryFileCreationFailed + + var errorDescription: String? { + switch self { + case .invalidStoredValue: + return "The stored value is not valid UTF-8." + case .temporaryFileCreationFailed: + return "The private storage temporary file could not be created." + } + } +} + +final class PrivateFileStore { + private static let directoryPermissions = 0o700 + private static let filePermissions = 0o600 + + private let fileManager: FileManager + private let rootDirectory: URL + private let namespaceDirectory: URL + private let queue: DispatchQueue + + convenience init(namespace: String) { + self.init( + namespace: namespace, + rootDirectory: Self.defaultRootDirectory() + ) + } + + init( + namespace: String, + rootDirectory: URL, + fileManager: FileManager = .default + ) { + precondition(Self.isValidNamespace(namespace), "Invalid private storage namespace") + self.fileManager = fileManager + self.rootDirectory = rootDirectory + namespaceDirectory = rootDirectory.appendingPathComponent( + namespace, + isDirectory: true + ) + queue = DispatchQueue( + label: "io.foxbiz.shellular.private-file-store.\(namespace)" + ) + } + + func get(_ key: String) throws -> String? { + try queue.sync { + guard try prepareExistingDirectory() else { return nil } + let url = valueURL(for: key) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + return nil + } + guard !isDirectory.boolValue else { + throw CocoaError(.fileReadCorruptFile) + } + try setPermissions(Self.filePermissions, at: url) + let data = try Data(contentsOf: url) + guard let value = String(data: data, encoding: .utf8) else { + throw PrivateFileStoreError.invalidStoredValue + } + return value + } + } + + func set(_ value: String, forKey key: String) throws { + try queue.sync { + try ensureDirectory() + let destination = valueURL(for: key) + let temporary = namespaceDirectory.appendingPathComponent( + ".\(UUID().uuidString).tmp", + isDirectory: false + ) + defer { try? fileManager.removeItem(at: temporary) } + + guard fileManager.createFile( + atPath: temporary.path, + contents: nil, + attributes: [.posixPermissions: Self.filePermissions] + ) else { + throw PrivateFileStoreError.temporaryFileCreationFailed + } + + try setPermissions(Self.filePermissions, at: temporary) + let handle = try FileHandle(forWritingTo: temporary) + do { + try handle.write(contentsOf: Data(value.utf8)) + try handle.synchronize() + try handle.close() + } catch { + try? handle.close() + throw error + } + + guard rename(temporary.path, destination.path) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + try setPermissions(Self.filePermissions, at: destination) + } + } + + func remove(_ key: String) throws { + try queue.sync { + guard try prepareExistingDirectory() else { return } + let url = valueURL(for: key) + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + } + } + + private static func defaultRootDirectory() -> URL { + FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + .appendingPathComponent("Shellular", isDirectory: true) + .appendingPathComponent("PrivateStore", isDirectory: true) + } + + private static func isValidNamespace(_ namespace: String) -> Bool { + !namespace.isEmpty && namespace.utf8.allSatisfy { character in + (character >= 48 && character <= 57) || + (character >= 65 && character <= 90) || + (character >= 97 && character <= 122) || + character == 45 || + character == 95 + } + } + + private func valueURL(for key: String) -> URL { + let digest = SHA256.hash(data: Data(key.utf8)) + let filename = digest.map { String(format: "%02x", $0) }.joined() + return namespaceDirectory.appendingPathComponent( + "\(filename).value", + isDirectory: false + ) + } + + private func prepareExistingDirectory() throws -> Bool { + var rootIsDirectory: ObjCBool = false + guard fileManager.fileExists( + atPath: rootDirectory.path, + isDirectory: &rootIsDirectory + ) else { + return false + } + guard rootIsDirectory.boolValue else { + throw CocoaError(.fileReadCorruptFile) + } + try setPermissions(Self.directoryPermissions, at: rootDirectory) + + var namespaceIsDirectory: ObjCBool = false + guard fileManager.fileExists( + atPath: namespaceDirectory.path, + isDirectory: &namespaceIsDirectory + ) else { + return false + } + guard namespaceIsDirectory.boolValue else { + throw CocoaError(.fileReadCorruptFile) + } + try setPermissions(Self.directoryPermissions, at: namespaceDirectory) + return true + } + + private func ensureDirectory() throws { + try ensureDirectory( + at: rootDirectory, + withIntermediateDirectories: true + ) + try ensureDirectory( + at: namespaceDirectory, + withIntermediateDirectories: false + ) + } + + private func ensureDirectory( + at url: URL, + withIntermediateDirectories: Bool + ) throws { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw CocoaError(.fileWriteFileExists) + } + try setPermissions(Self.directoryPermissions, at: url) + return + } + + do { + try fileManager.createDirectory( + at: url, + withIntermediateDirectories: withIntermediateDirectories, + attributes: [.posixPermissions: Self.directoryPermissions] + ) + } catch { + var wasCreatedByAnotherWriter: ObjCBool = false + guard fileManager.fileExists( + atPath: url.path, + isDirectory: &wasCreatedByAnotherWriter + ), wasCreatedByAnotherWriter.boolValue else { + throw error + } + } + try setPermissions(Self.directoryPermissions, at: url) + } + + private func setPermissions(_ permissions: Int, at url: URL) throws { + try fileManager.setAttributes( + [.posixPermissions: permissions], + ofItemAtPath: url.path + ) + } +} diff --git a/platforms/macos/shellular/Services.swift b/platforms/macos/shellular/Services.swift index f2926b1..e38efe1 100644 --- a/platforms/macos/shellular/Services.swift +++ b/platforms/macos/shellular/Services.swift @@ -2,11 +2,256 @@ import AppKit import AVFoundation import CryptoKit import Photos -import Security import UserNotifications +import WebKit + +final class ContextMenuService: BaseService { + private struct Request { + let id: Int + let trigger: String + let anchor: [String: Any] + let viewport: ContextMenuViewportMetrics + let items: [[String: Any]] + let callback: Callback + } + + private final class MenuCommandReference: NSObject { + let requestID: Int + let commandID: String + + init(requestID: Int, commandID: String) { + self.requestID = requestID + self.commandID = commandID + } + } + + private var coordinator = ContextMenuRequestCoordinator() + private var requests: [Int: Request] = [:] + private var activeMenu: NSMenu? + private var selectedCommands: [Int: String] = [:] + + override func exec(action: String, args: [Any], callback: Callback) { + switch action { + case "show": show(args, callback) + case "cancel": + DispatchQueue.main.async { + self.apply(self.coordinator.cancelAll(), deferPresentation: true) + callback.success() + } + default: callback.error("Unknown action: \(action)") + } + } + + private func show(_ args: [Any], _ callback: Callback) { + guard let request = args.first as? [String: Any], + let id = integer(request["id"]), + let trigger = request["trigger"] as? String, + ["context", "button", "keyboard"].contains(trigger), + let rawItems = request["items"] as? [[String: Any]], + let anchor = request["anchor"] as? [String: Any], + let rawViewport = request["viewport"] as? [String: Any], + let viewport = viewport(rawViewport) else { + return callback.error("Invalid context menu request") + } + DispatchQueue.main.async { + guard self.webView != nil else { return callback.error("No workbench view") } + guard id > 0, + self.requests[id] == nil, + self.coordinator.activeID != id, + self.coordinator.pendingID != id else { + return callback.error("Duplicate context menu request") + } + guard rawItems.contains(where: { ($0["type"] as? String) != "separator" }) else { + return callback.success(nil) + } + self.requests[id] = Request( + id: id, + trigger: trigger, + anchor: anchor, + viewport: viewport, + items: rawItems, + callback: callback + ) + self.apply(self.coordinator.submit(id), deferPresentation: false) + } + } + + private func present(_ id: Int) { + guard coordinator.canPresent(id), + let request = requests[id], + let webView else { + apply(coordinator.trackingEnded(id, selection: nil), deferPresentation: true) + return + } + let menu = makeMenu(request.items, requestID: id) + guard menu.items.contains(where: { !$0.isSeparatorItem }) else { + apply(coordinator.trackingEnded(id, selection: nil), deferPresentation: true) + return + } + activeMenu = menu + let point = anchorPoint(for: request, in: webView) + if request.trigger == "context", + let event = (webView as? RecentPointerEventProviding)?.recentContextEvent(near: point) { + NSMenu.popUpContextMenu(menu, with: event, for: webView) + } else { + _ = menu.popUp(positioning: nil, at: point, in: webView) + } + if activeMenu === menu { activeMenu = nil } + let selection = selectedCommands.removeValue(forKey: id) + apply(coordinator.trackingEnded(id, selection: selection), deferPresentation: true) + } + + private func apply(_ actions: [ContextMenuCoordinatorAction], deferPresentation: Bool) { + for action in actions { + switch action { + case .present(let id): + if deferPresentation { + DispatchQueue.main.async { [weak self] in self?.present(id) } + } else { + present(id) + } + case .cancelTracking: + activeMenu?.cancelTracking() + case .complete(let id, let command): + selectedCommands[id] = nil + requests.removeValue(forKey: id)?.callback.success(command) + } + } + } + + private func anchorPoint(for request: Request, in webView: WKWebView) -> CGPoint { + let isRectangle = request.anchor["kind"] as? String == "rect" + let x: CGFloat + let y: CGFloat + if isRectangle && request.trigger == "keyboard" { + x = number(request.anchor["right"]) + y = number(request.anchor["top"]) + } else if isRectangle { + x = number(request.anchor["left"]) + y = number(request.anchor["bottom"]) + } else { + x = number(request.anchor["x"]) + y = number(request.anchor["y"]) + } + return ContextMenuCoordinateConverter.convert( + CGPoint(x: x, y: y), + viewport: request.viewport, + viewBounds: webView.bounds, + isFlipped: webView.isFlipped + ) + } + + private func makeMenu(_ rawItems: [[String: Any]], requestID: Int) -> NSMenu { + let menu = NSMenu(title: "") + menu.autoenablesItems = false + menu.showsStateColumn = true + for raw in rawItems { + switch raw["type"] as? String { + case "separator": menu.addItem(.separator()) + case "submenu": + guard let title = raw["label"] as? String, + let children = raw["items"] as? [[String: Any]] else { continue } + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.submenu = makeMenu(children, requestID: requestID) + applyImage(raw["macSymbol"] as? String, to: item) + menu.addItem(item) + case "command": + guard let command = raw["command"] as? String, + let title = raw["label"] as? String else { continue } + let item = NSMenuItem(title: title, action: #selector(selectCommand(_:)), keyEquivalent: "") + item.target = self + item.representedObject = MenuCommandReference(requestID: requestID, commandID: command) + item.isEnabled = !(raw["disabled"] as? Bool ?? false) + if raw["checked"] as? Bool == true { item.state = .on } + if raw["danger"] as? Bool == true { + item.attributedTitle = NSAttributedString( + string: title, + attributes: [.foregroundColor: NSColor.systemRed] + ) + } + applyImage(raw["macSymbol"] as? String, to: item) + applyShortcut(raw["shortcut"] as? [String: Any], to: item) + menu.addItem(item) + default: continue + } + } + return menu + } + + private func applyImage(_ symbol: String?, to item: NSMenuItem) { + guard let symbol, !symbol.isEmpty else { return } + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + } + + private func applyShortcut(_ raw: [String: Any]?, to item: NSMenuItem) { + guard let key = raw?["key"] as? String else { return } + item.keyEquivalent = keyEquivalent(key) + let modifiers = raw?["modifiers"] as? [String] ?? [] + item.keyEquivalentModifierMask = modifiers.reduce(into: NSEvent.ModifierFlags()) { result, value in + switch value { + case "meta": result.insert(.command) + case "ctrl": result.insert(.control) + case "alt": result.insert(.option) + case "shift": result.insert(.shift) + default: break + } + } + } + + private func keyEquivalent(_ key: String) -> String { + switch key.uppercased() { + case "F2": return String(Character(UnicodeScalar(NSF2FunctionKey)!)) + case "F12": return String(Character(UnicodeScalar(NSF12FunctionKey)!)) + default: return key.lowercased() + } + } + + private func number(_ value: Any?) -> CGFloat { + if let number = value as? NSNumber { return CGFloat(number.doubleValue) } + return 0 + } + + private func integer(_ value: Any?) -> Int? { + (value as? NSNumber)?.intValue + } + + private func viewport(_ raw: [String: Any]) -> ContextMenuViewportMetrics? { + let required = [ + "layoutWidth", "layoutHeight", "visualWidth", "visualHeight", + "visualOffsetLeft", "visualOffsetTop", "visualScale", "deviceScaleFactor" + ] + guard required.allSatisfy({ raw[$0] is NSNumber }) else { return nil } + return ContextMenuViewportMetrics( + layoutWidth: number(raw["layoutWidth"]), + layoutHeight: number(raw["layoutHeight"]), + visualWidth: number(raw["visualWidth"]), + visualHeight: number(raw["visualHeight"]), + visualOffsetLeft: number(raw["visualOffsetLeft"]), + visualOffsetTop: number(raw["visualOffsetTop"]), + visualScale: number(raw["visualScale"]), + deviceScaleFactor: number(raw["deviceScaleFactor"]) + ) + } + + @objc private func selectCommand(_ sender: NSMenuItem) { + guard let reference = sender.representedObject as? MenuCommandReference, + coordinator.canSelect(reference.requestID) else { return } + selectedCommands[reference.requestID] = reference.commandID + } +} final class NativeService: BaseService { + private struct DesktopCommandHandler { + let id: String + let callback: Callback + } + private static var desktopCommandHandler: DesktopCommandHandler? private lazy var socket = SocketService(bridge: bridge!) + static func sendDesktopCommand(_ command: String) { + DispatchQueue.main.async { + desktopCommandHandler?.callback.success(command, keep: true) + } + } override func exec(action: String, args: [Any], callback: Callback) { switch action { case "shareFile": share(items: args.compactMap { ($0 as? String).map { URL(fileURLWithPath: $0) } }, callback) @@ -15,7 +260,66 @@ final class NativeService: BaseService { case "getDeviceInfo": callback.success(["manufacturer":"Apple", "model": Host.current().localizedName ?? "Mac", "product": ProcessInfo.processInfo.operatingSystemVersionString, "isEmulator":false]) case "openInBrowser": open(args, callback) case "setIntentHandler": AppDelegate.shared?.deepLinkHandler = { callback.success($0.absoluteString, keep: true) }; callback.success(nil, keep: true) - case "setTheme": if let value = args.first as? [String:Any], value["type"] as? String == "light" { NSApp.appearance = NSAppearance(named: .aqua) } else { NSApp.appearance = NSAppearance(named: .darkAqua) }; callback.success() + case "setDesktopCommandHandler": + guard let id = args.first as? String, !id.isEmpty else { + callback.error("Desktop command registration ID is required") + return + } + Self.desktopCommandHandler?.callback.success() + Self.desktopCommandHandler = DesktopCommandHandler(id: id, callback: callback) + case "clearDesktopCommandHandler": + if let id = args.first as? String, Self.desktopCommandHandler?.id == id { + Self.desktopCommandHandler?.callback.success() + Self.desktopCommandHandler = nil + } + callback.success() + case "getDesktopCapabilities": callback.success(["localWorkspace": true, "canPickLocalFiles": true, "canRevealLocalPath": true, "canOpenSystemTerminal": false]) + case "pickLocalFiles": pickLocalFiles(args, callback) + case "pickLocalDirectory": pickLocalDirectory(args, callback) + case "revealLocalPath": revealLocalPath(args, callback) + case "openSystemTerminal": callback.error("System terminal is unavailable") + case "setWindowTitle": + let rawTitle = args.first as? String ?? "Shellular" + DispatchQueue.main.async { self.viewController?.setWindowTitle(rawTitle); callback.success() } + case "setDesktopShortcutContext": + let context = args.first as? [String: Any] + let contextualNew = context?["contextualNew"] as? String + let shortcuts = context?["shortcuts"] as? [String: Any] + DispatchQueue.main.async { + AppDelegate.shared?.updateDesktopShortcutContext( + contextualNew, + shortcuts: shortcuts + ) + callback.success() + } + case "loadBundledAsset": + guard let name = args.first as? String, + ["monaco/editor.worker.js", "monaco/json.worker.js", "monaco/css.worker.js", "monaco/html.worker.js", "monaco/ts.worker.js"].contains(name), + let root = Bundle.main.resourceURL, + let source = try? String(contentsOf: root.appendingPathComponent("bundle").appendingPathComponent(name), encoding: .utf8) + else { return callback.error("Bundled editor worker is unavailable") } + callback.success(source) + case "readClipboardText": + DispatchQueue.main.async { + callback.success(NSPasteboard.general.string(forType: .string) ?? "") + } + case "writeClipboardText": + let text = args.first as? String ?? "" + DispatchQueue.main.async { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + callback.success() + } + case "setTheme": + guard let value = args.first as? [String: Any] else { + return callback.error("Theme data is required") + } + NSApp.appearance = NSAppearance( + named: value["type"] as? String == "light" ? .aqua : .darkAqua + ) + viewController?.setTheme(value) + callback.success() case "requestPermission": permission(args, request: true, callback) case "requestPermissions": requestMany(args, callback) case "hasPermission": permission(args, request: false, callback) @@ -32,6 +336,61 @@ final class NativeService: BaseService { } private func share(items: [Any], _ callback: Callback) { DispatchQueue.main.async { guard let view = self.viewController?.view else { return callback.error("No window") }; NSSharingServicePicker(items: items).show(relativeTo: view.bounds, of: view, preferredEdge: .minY); callback.success() } } private func open(_ args: [Any], _ callback: Callback) { guard let s = args.first as? String, let u = URL(string:s) else { return callback.error("Invalid URL") }; NSWorkspace.shared.open(u); callback.success() } + private func pickLocalFiles(_ args: [Any], _ callback: Callback) { + DispatchQueue.main.async { + guard let window = self.viewController?.view.window else { return callback.error("No window") } + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseFiles = true + panel.canChooseDirectories = false + if let rootPath = args.first as? String, !rootPath.isEmpty { + panel.directoryURL = URL(fileURLWithPath: rootPath, isDirectory: true) + } + panel.beginSheetModal(for: window) { response in + guard response == .OK else { return callback.success([]) } + callback.success(panel.urls.map(\.path)) + } + } + } + private func pickLocalDirectory(_ args: [Any], _ callback: Callback) { + guard let rootPath = args.first as? String, rootPath.hasPrefix("/") else { + return callback.error("A local workspace root is required") + } + let rootURL = URL(fileURLWithPath: rootPath, isDirectory: true) + .standardizedFileURL.resolvingSymlinksInPath() + DispatchQueue.main.async { + guard let window = self.viewController?.view.window else { return callback.error("No window") } + let panel = NSOpenPanel() + panel.title = "Open Folder" + panel.prompt = "Open" + panel.allowsMultipleSelection = false + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.resolvesAliases = true + panel.directoryURL = rootURL + panel.beginSheetModal(for: window) { response in + guard response == .OK else { return callback.success(nil) } + guard let selected = panel.url?.standardizedFileURL.resolvingSymlinksInPath() else { + return callback.success(nil) + } + let root = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/" + guard selected.path != rootURL.path, selected.path.hasPrefix(root) else { + return callback.error("Choose a folder inside the connected workspace root") + } + callback.success(selected.path) + } + } + } + private func revealLocalPath(_ args: [Any], _ callback: Callback) { + guard let rawPath = args.first as? String, rawPath.hasPrefix("/") else { return callback.error("Absolute path required") } + let url = URL(fileURLWithPath: rawPath).standardizedFileURL + guard FileManager.default.fileExists(atPath: url.path) else { return callback.error("Path does not exist") } + DispatchQueue.main.async { + NSWorkspace.shared.activateFileViewerSelecting([url]) + callback.success() + } + } private func permission(_ args: [Any], request: Bool, _ callback: Callback) { let p = (args.first as? String ?? "").lowercased() if p.contains("camera") { @@ -62,7 +421,39 @@ final class DialogService: BaseService { final class EncryptionService: BaseService { override func exec(action:String,args:[Any],callback:Callback) { guard let text=args[safe:0] as? String, let password=args[safe:1] as? String, !password.isEmpty else{return callback.error("message and password required")}; let key=Array(password.utf8); if action=="encrypt" { callback.success(Data(Array(text.utf8).enumerated().map{$0.element ^ key[$0.offset % key.count]}).base64EncodedString()) } else if action=="decrypt", let data=Data(base64Encoded:text), let value=String(bytes:data.enumerated().map{$0.element ^ key[$0.offset % key.count]},encoding:.utf8){callback.success(value)} else {callback.error("Invalid encrypted data")} } } -final class SecureStoreService: BaseService { override func exec(action:String,args:[Any],callback:Callback) { guard let key=args.first as? String else{return callback.error("key required")}; let query:[String:Any]=[kSecClass as String:kSecClassGenericPassword,kSecAttrService as String:"io.foxbiz.shellular.secure-store",kSecAttrAccount as String:key]; if action=="remove"{SecItemDelete(query as CFDictionary);return callback.success()}; if action=="get"{var item:CFTypeRef?;var q=query;q[kSecReturnData as String]=true; let s=SecItemCopyMatching(q as CFDictionary,&item); return callback.success(s==errSecSuccess ? (item as? Data).flatMap{String(data:$0,encoding:.utf8)}:nil)}; guard let value=args[safe:1] as? String, let data=value.data(using:.utf8) else{return callback.error("value required")}; SecItemDelete(query as CFDictionary);var q=query;q[kSecValueData as String]=data; SecItemAdd(q as CFDictionary,nil)==errSecSuccess ? callback.success():callback.error("Keychain write failed") } } +final class SecureStoreService: BaseService { + private let store = PrivateFileStore(namespace: "secure-store") + + override func exec(action: String, args: [Any], callback: Callback) { + guard let key = args.first as? String else { + return callback.error("key required") + } + + do { + switch action { + case "get": + if let value = try store.get(key) { + callback.success(value) + } else { + callback.success(nil) + } + case "set": + guard let value = args[safe: 1] as? String else { + return callback.error("value required") + } + try store.set(value, forKey: key) + callback.success() + case "remove": + try store.remove(key) + callback.success() + default: + callback.error("Unknown action: \(action)") + } + } catch { + callback.error("Private storage \(action) failed") + } + } +} final class NotificationService: BaseService { static let shared = NotificationService(); private var entries:[Int:UNMutableNotificationContent]=[:]; private var listeners:[Int:Callback]=[:]; private var next=1 diff --git a/platforms/macos/shellular/ShellularWindow.swift b/platforms/macos/shellular/ShellularWindow.swift new file mode 100644 index 0000000..9652d83 --- /dev/null +++ b/platforms/macos/shellular/ShellularWindow.swift @@ -0,0 +1,185 @@ +import AppKit + +final class ShellularWindow: NSWindow { + private static let animationDuration: TimeInterval = 0.18 + private static let meaningfulSizeDelta: CGFloat = 8 + + private var titlebarDragGeneration = 0 + private var pendingTitlebarTileAnimation = false + private var titlebarDragStartFrame: NSRect? + private var isForwardingFrameChange = false + private var usesShellularAnimationDuration = false + private var restoredFrame: NSRect? + private var frameAnimationTimer: Timer? + private var frameAnimationTarget: NSRect? + + func beginTitlebarDrag() { + cancelFrameAnimation() + titlebarDragGeneration += 1 + pendingTitlebarTileAnimation = true + titlebarDragStartFrame = frame + } + + func endTitlebarDrag() { + let generation = titlebarDragGeneration + // WindowServer applies a tiling target just after performDrag returns. + // The first meaningful size change consumes this intent; this timeout is + // only a safety expiry and cannot animate unrelated later resizes. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self, self.titlebarDragGeneration == generation else { return } + self.pendingTitlebarTileAnimation = false + self.titlebarDragStartFrame = nil + } + } + + func performConfiguredTitlebarDoubleClick() { + switch TitlebarDoubleClickAction.resolve( + UserDefaults.standard.string(forKey: "AppleActionOnDoubleClick") + ) { + case .minimize: + miniaturize(nil) + case .fill: + toggleFilledFrame() + case .none: + break + } + } + + override func setFrame(_ frameRect: NSRect, display flag: Bool) { + guard !isForwardingFrameChange else { + super.setFrame(frameRect, display: flag) + return + } + forwardFrame(frameRect, display: flag, requestedAnimation: false) + } + + override func setFrame(_ frameRect: NSRect, display flag: Bool, animate animateFlag: Bool) { + guard !isForwardingFrameChange else { + super.setFrame(frameRect, display: flag, animate: animateFlag) + return + } + forwardFrame(frameRect, display: flag, requestedAnimation: animateFlag) + } + + override func animationResizeTime(_ newFrame: NSRect) -> TimeInterval { + usesShellularAnimationDuration + ? Self.animationDuration + : super.animationResizeTime(newFrame) + } + + private func forwardFrame(_ target: NSRect, display: Bool, requestedAnimation: Bool) { + if frameAnimationTimer != nil, + let frameAnimationTarget, + framesMatch(target, frameAnimationTarget) { + return + } + cancelFrameAnimation() + let tileAnimation = consumeTileAnimationIfNeeded(for: target) + let shouldAnimate = requestedAnimation || tileAnimation + isForwardingFrameChange = true + usesShellularAnimationDuration = tileAnimation + super.setFrame(target, display: display, animate: shouldAnimate) + usesShellularAnimationDuration = false + isForwardingFrameChange = false + } + + private func consumeTileAnimationIfNeeded(for target: NSRect) -> Bool { + guard pendingTitlebarTileAnimation, + !inLiveResize, + !styleMask.contains(.fullScreen), + isVisible, + !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { + return false + } + let meaningful = abs(frame.width - target.width) >= Self.meaningfulSizeDelta || + abs(frame.height - target.height) >= Self.meaningfulSizeDelta + if meaningful { + pendingTitlebarTileAnimation = false + if let targetScreen = screenContaining(target), + framesMatch(target, targetScreen.visibleFrame) { + restoredFrame = titlebarDragStartFrame + } + titlebarDragStartFrame = nil + } + return meaningful + } + + private func toggleFilledFrame() { + guard !styleMask.contains(.fullScreen), let screen else { return } + let filledFrame = constrainFrameRect(screen.visibleFrame, to: screen) + let target: NSRect + if framesMatch(frame, filledFrame), let restoredFrame { + target = constrainFrameRect(restoredFrame, to: screenContaining(restoredFrame) ?? screen) + self.restoredFrame = nil + } else { + restoredFrame = frame + target = filledFrame + } + + animateFrame(to: target) + } + + private func animateFrame(to target: NSRect) { + cancelFrameAnimation() + let source = frame + guard !framesMatch(source, target) else { return } + + let startedAt = ProcessInfo.processInfo.systemUptime + let framesPerSecond = max(screen?.maximumFramesPerSecond ?? 60, 60) + let timer = Timer(timeInterval: 1 / Double(framesPerSecond), repeats: true) { [weak self] timer in + guard let self else { + timer.invalidate() + return + } + let elapsed = ProcessInfo.processInfo.systemUptime - startedAt + let linearProgress = min(elapsed / Self.animationDuration, 1) + let progress = WindowFrameAnimationTiming.easedProgress( + elapsed: elapsed, + duration: Self.animationDuration + ) + let easedProgress = CGFloat(progress) + let nextFrame = NSRect( + x: source.origin.x + (target.origin.x - source.origin.x) * easedProgress, + y: source.origin.y + (target.origin.y - source.origin.y) * easedProgress, + width: source.width + (target.width - source.width) * easedProgress, + height: source.height + (target.height - source.height) * easedProgress + ) + self.applyAnimatedFrame(linearProgress >= 1 ? target : nextFrame) + if linearProgress >= 1 { + timer.invalidate() + self.frameAnimationTimer = nil + self.frameAnimationTarget = nil + } + } + timer.tolerance = 0 + frameAnimationTarget = target + frameAnimationTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + private func applyAnimatedFrame(_ target: NSRect) { + isForwardingFrameChange = true + super.setFrame(target, display: true) + isForwardingFrameChange = false + } + + private func cancelFrameAnimation() { + frameAnimationTimer?.invalidate() + frameAnimationTimer = nil + frameAnimationTarget = nil + } + + private func screenContaining(_ frame: NSRect) -> NSScreen? { + NSScreen.screens.max { + $0.frame.intersection(frame).width * $0.frame.intersection(frame).height < + $1.frame.intersection(frame).width * $1.frame.intersection(frame).height + } + } + + private func framesMatch(_ left: NSRect, _ right: NSRect) -> Bool { + abs(left.minX - right.minX) < 2 && + abs(left.minY - right.minY) < 2 && + abs(left.width - right.width) < 2 && + abs(left.height - right.height) < 2 + } +} diff --git a/platforms/macos/shellular/WebView.swift b/platforms/macos/shellular/WebView.swift index 74a34d9..ab427d7 100644 --- a/platforms/macos/shellular/WebView.swift +++ b/platforms/macos/shellular/WebView.swift @@ -1,49 +1,622 @@ -import SwiftUI import AppKit +import QuartzCore import WebKit -struct WebContainer: NSViewControllerRepresentable { - func makeNSViewController(context: Context) -> WebViewController { WebViewController() } - func updateNSViewController(_ controller: WebViewController, context: Context) {} +protocol RecentPointerEventProviding: AnyObject { + func recentContextEvent(near point: CGPoint) -> NSEvent? +} + +private final class ShellularWorkbenchWebView: WKWebView, RecentPointerEventProviding { + private struct CapturedPointerEvent { + let event: NSEvent + let capturedAt: TimeInterval + } + + private var recentPointerEvents: [CapturedPointerEvent] = [] + + override func menu(for event: NSEvent) -> NSMenu? { + capture(event) + return nil + } + + override func mouseDown(with event: NSEvent) { + capture(event) + super.mouseDown(with: event) + } + + override func rightMouseDown(with event: NSEvent) { + capture(event) + super.rightMouseDown(with: event) + } + + override func otherMouseDown(with event: NSEvent) { + capture(event) + super.otherMouseDown(with: event) + } + + func recentContextEvent(near point: CGPoint) -> NSEvent? { + let now = ProcessInfo.processInfo.systemUptime + recentPointerEvents.removeAll { now - $0.capturedAt > 1.25 } + let candidates = recentPointerEvents.enumerated().filter { _, captured in + let event = captured.event + let isContextEvent = event.type == .rightMouseDown || + (event.type == .leftMouseDown && event.modifierFlags.contains(.control)) + return isContextEvent && event.window === window + } + guard let match = candidates.min(by: { first, second in + distance(from: first.element.event, to: point) < + distance(from: second.element.event, to: point) + }) else { return nil } + let event = match.element.event + // One DOM context-menu invocation consumes the captured native event. + // Discard duplicates recorded by both rightMouseDown and menu(for:). + recentPointerEvents.removeAll { captured in + captured.event.type == .rightMouseDown || + (captured.event.type == .leftMouseDown && captured.event.modifierFlags.contains(.control)) + } + return event + } + + private func distance(from event: NSEvent, to point: CGPoint) -> CGFloat { + let eventPoint = convert(event.locationInWindow, from: nil) + return hypot(eventPoint.x - point.x, eventPoint.y - point.y) + } + + private func capture(_ event: NSEvent) { + guard event.type == .leftMouseDown || + event.type == .rightMouseDown || + event.type == .otherMouseDown else { return } + let now = ProcessInfo.processInfo.systemUptime + recentPointerEvents.removeAll { now - $0.capturedAt > 1.25 } + recentPointerEvents.append(CapturedPointerEvent(event: event, capturedAt: now)) + if recentPointerEvents.count > 8 { + recentPointerEvents.removeFirst(recentPointerEvents.count - 8) + } + } +} + +private final class BrowserSidebarDivider: NSView { + weak var splitView: WorkbenchRootView? + private var dragging = false + private var cursorRectsDisabled = false + private var cursorWasPushed = false + private weak var cursorWindow: NSWindow? + private var windowResignObserver: NSObjectProtocol? + var separatorColor = NSColor.separatorColor { + didSet { needsDisplay = true } + } + + override var acceptsFirstResponder: Bool { true } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + separatorColor.setFill() + NSRect(x: floor(bounds.midX), y: bounds.minY, width: 1, height: bounds.height).fill() + } + + override func resetCursorRects() { + addCursorRect( + BrowserDividerCursorRegion.rect(in: bounds), + cursor: .resizeLeftRight + ) + } + + override func mouseDown(with event: NSEvent) { + guard let splitView else { return } + if event.clickCount == 2 { + splitView.resetBrowserSidebarWidth() + return + } + dragging = true + beginCursorSession() + splitView.beginBrowserSidebarResize() + } + + override func mouseDragged(with event: NSEvent) { + guard dragging, let splitView else { return } + NSCursor.resizeLeftRight.set() + let point = splitView.convert(event.locationInWindow, from: nil) + splitView.setBrowserSidebarWidth(splitView.bounds.maxX - point.x) + } + + override func mouseUp(with event: NSEvent) { + finishDragging() + } + + override func cancelOperation(_ sender: Any?) { + finishDragging() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if dragging, cursorWindow !== window { + finishDragging() + } + stopObservingWindow() + guard let window else { + finishDragging() + return + } + windowResignObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + self?.finishDragging() + } + } + + override func keyDown(with event: NSEvent) { + guard let splitView else { return super.keyDown(with: event) } + let delta: CGFloat + switch event.keyCode { + case 123: delta = 10 + case 124: delta = -10 + default: return super.keyDown(with: event) + } + splitView.setBrowserSidebarWidth(splitView.browserSidebarWidth + delta) + splitView.persistBrowserSidebarWidth() + } + + deinit { + stopObservingWindow() + endCursorSession() + } + + private func beginCursorSession() { + guard !cursorWasPushed else { return } + cursorWindow = window + if let cursorWindow { + cursorWindow.disableCursorRects() + cursorRectsDisabled = true + } + NSCursor.resizeLeftRight.push() + cursorWasPushed = true + } + + private func finishDragging() { + guard dragging else { + endCursorSession() + return + } + dragging = false + splitView?.endBrowserSidebarResize() + endCursorSession() + } + + private func endCursorSession() { + if cursorWasPushed { + NSCursor.pop() + cursorWasPushed = false + } + if cursorRectsDisabled { + cursorWindow?.enableCursorRects() + cursorRectsDisabled = false + } + if let cursorWindow { + cursorWindow.invalidateCursorRects(for: self) + } + cursorWindow = nil + } + + private func stopObservingWindow() { + guard let windowResignObserver else { return } + NotificationCenter.default.removeObserver(windowResignObserver) + self.windowResignObserver = nil + } +} + +private final class WorkbenchRootView: NSView { + let workbenchHostView = NSView() + let browserSidebarHostView = NSView() + private let workbenchResizeUnderlay = BrowserResizeUnderlayView() + private let browserResizeUnderlay = BrowserResizeUnderlayView() + private let browserSidebarDivider = BrowserSidebarDivider() + private let browserSidebarWidthStore: BrowserSidebarWidthStore + private weak var workbenchContentView: NSView? + private weak var browserSidebarContentView: NSView? + private var resizeLifecycle = BrowserResizeLifecycle() + private(set) var browserSidebarVisible = false + private(set) var browserSidebarWidth: CGFloat + var backgroundColor = NSColor.windowBackgroundColor { + didSet { + needsDisplay = true + updateResizeUnderlays() + } + } + var chromeBackgroundColor = NSColor.windowBackgroundColor { + didSet { updateResizeUnderlays() } + } + var browserSeparatorColor = NSColor.separatorColor { + didSet { browserSidebarDivider.separatorColor = browserSeparatorColor } + } + var onLiveResizeChange: ((Bool) -> Void)? + var onWorkbenchLayout: (() -> Void)? + + override init(frame frameRect: NSRect) { + let widthStore = BrowserSidebarWidthStore() + browserSidebarWidthStore = widthStore + browserSidebarWidth = widthStore.load() + super.init(frame: frameRect) + browserSidebarDivider.splitView = self + browserSidebarDivider.wantsLayer = true + browserSidebarDivider.setAccessibilityRole(.splitter) + browserSidebarDivider.setAccessibilityLabel("Resize browser sidebar") + browserSidebarHostView.isHidden = true + workbenchHostView.wantsLayer = true + browserSidebarHostView.wantsLayer = true + layerContentsRedrawPolicy = .duringViewResize + workbenchHostView.layerContentsRedrawPolicy = .duringViewResize + browserSidebarHostView.layerContentsRedrawPolicy = .duringViewResize + workbenchHostView.layer?.masksToBounds = true + browserSidebarHostView.layer?.masksToBounds = true + workbenchResizeUnderlay.autoresizingMask = [.width, .height] + browserResizeUnderlay.autoresizingMask = [.width, .height] + workbenchHostView.addSubview(workbenchResizeUnderlay) + browserSidebarHostView.addSubview(browserResizeUnderlay) + updateResizeUnderlays() + addSubview(workbenchHostView) + addSubview(browserSidebarHostView) + addSubview(browserSidebarDivider) + browserSidebarDivider.isHidden = true + } + + required init?(coder: NSCoder) { fatalError() } + + override var wantsUpdateLayer: Bool { true } + + override func updateLayer() { + layer?.backgroundColor = backgroundColor.cgColor + workbenchHostView.layer?.backgroundColor = backgroundColor.cgColor + browserSidebarHostView.layer?.backgroundColor = backgroundColor.cgColor + updateResizeUnderlays() + } + + override func viewWillStartLiveResize() { + super.viewWillStartLiveResize() + notifyResizeStarted(.window) + } + + override func viewDidEndLiveResize() { + super.viewDidEndLiveResize() + needsLayout = true + layoutSubtreeIfNeeded() + notifyResizeEnded(.window) + } + + override func layout() { + super.layout() + let frames = BrowserSidebarMetrics.layout( + in: bounds, + preferredWidth: browserSidebarWidth, + isVisible: browserSidebarVisible + ) + browserSidebarWidth = frames.sidebarFrame.width + let previousDividerFrame = browserSidebarDivider.frame + CATransaction.begin() + CATransaction.setDisableActions(true) + workbenchHostView.frame = frames.workbenchFrame + browserSidebarHostView.frame = frames.sidebarFrame + browserSidebarDivider.frame = frames.dividerFrame + workbenchResizeUnderlay.frame = workbenchHostView.bounds + browserResizeUnderlay.frame = browserSidebarHostView.bounds + workbenchResizeUnderlay.layoutSubtreeIfNeeded() + browserResizeUnderlay.layoutSubtreeIfNeeded() + if let workbenchContentView { + // The controller registers its WebView before the first root + // layout, when this host can still be zero-width. Retrying the + // attachment here completes that deferred startup safely. + BrowserViewAttachment.attach(workbenchContentView, to: workbenchHostView) + } + BrowserViewAttachment.synchronize(browserSidebarContentView, with: browserSidebarHostView) + CATransaction.commit() + if inLiveResize { window?.viewsNeedDisplay = true } + onWorkbenchLayout?() + if previousDividerFrame != frames.dividerFrame, let window { + window.invalidateCursorRects(for: browserSidebarDivider) + } + } + + func attachWorkbenchContent(_ content: NSView) { + workbenchContentView = content + if !BrowserViewAttachment.attach(content, to: workbenchHostView) { + needsLayout = true + layoutSubtreeIfNeeded() + } + } + + func showBrowserSidebar(_ content: NSView) { + browserSidebarContentView = content + browserSidebarVisible = true + browserSidebarHostView.isHidden = false + browserSidebarDivider.isHidden = false + needsLayout = true + layoutSubtreeIfNeeded() + BrowserViewAttachment.attach(content, to: browserSidebarHostView) + content.layoutSubtreeIfNeeded() + window?.invalidateCursorRects(for: browserSidebarDivider) + } + + func hideBrowserSidebar() { + browserSidebarVisible = false + browserSidebarHostView.isHidden = true + browserSidebarDivider.isHidden = true + needsLayout = true + layoutSubtreeIfNeeded() + window?.invalidateCursorRects(for: browserSidebarDivider) + } + + func setBrowserSidebarWidth(_ value: CGFloat) { + browserSidebarWidth = clampedBrowserSidebarWidth(value) + needsLayout = true + layoutSubtreeIfNeeded() + } + + func resetBrowserSidebarWidth() { + setBrowserSidebarWidth(BrowserSidebarMetrics.defaultWidth) + persistBrowserSidebarWidth() + } + + func beginBrowserSidebarResize() { + notifyResizeStarted(.sidebar) + } + + func endBrowserSidebarResize() { + persistBrowserSidebarWidth() + needsLayout = true + layoutSubtreeIfNeeded() + notifyResizeEnded(.sidebar) + } + + func persistBrowserSidebarWidth() { + browserSidebarWidthStore.save(browserSidebarWidth) + } + + var workbenchBounds: NSRect { workbenchHostView.bounds } + + private func clampedBrowserSidebarWidth(_ value: CGFloat) -> CGFloat { + BrowserSidebarMetrics.clampedWidth(value, containerWidth: bounds.width) + } + + private func updateResizeUnderlays() { + workbenchResizeUnderlay.apply(content: backgroundColor, chrome: chromeBackgroundColor) + browserResizeUnderlay.apply(content: backgroundColor, chrome: chromeBackgroundColor) + } + + private func notifyResizeStarted(_ source: BrowserResizeSource) { + if resizeLifecycle.begin(source) { + onLiveResizeChange?(true) + } + } + + private func notifyResizeEnded(_ source: BrowserResizeSource) { + if resizeLifecycle.end(source) { + onLiveResizeChange?(false) + } + } +} + +private final class TitlebarDragView: NSView { + override func mouseDown(with event: NSEvent) { + guard let window = window as? ShellularWindow else { return } + if event.clickCount == 2 { + window.performConfiguredTitlebarDoubleClick() + return + } + window.beginTitlebarDrag() + window.performDrag(with: event) + window.endTitlebarDrag() + } +} + +private final class WindowChromeController { + static let titlebarHeight: CGFloat = 40 + + private weak var window: NSWindow? + private weak var titlebarHostView: NSView? + private var observers: [NSObjectProtocol] = [] + + deinit { + stopObserving() + } + + func attach(to nextWindow: NSWindow?, titlebarHostView: NSView) { + self.titlebarHostView = titlebarHostView + guard window !== nextWindow else { + layout() + return + } + stopObserving() + window = nextWindow + guard let nextWindow else { return } + + nextWindow.styleMask.insert(.fullSizeContentView) + nextWindow.titleVisibility = .hidden + nextWindow.titlebarAppearsTransparent = true + nextWindow.titlebarSeparatorStyle = .none + nextWindow.tabbingMode = .disallowed + BrowserLiveResizePolicy.apply(to: nextWindow, views: [titlebarHostView]) + + let center = NotificationCenter.default + let notifications: [Notification.Name] = [ + NSWindow.didEnterFullScreenNotification, + NSWindow.didExitFullScreenNotification, + NSWindow.didChangeBackingPropertiesNotification, + ] + observers = notifications.map { name in + center.addObserver(forName: name, object: nextWindow, queue: .main) { [weak self, weak nextWindow] _ in + guard let self, let nextWindow else { return } + self.titlebarHostView?.needsLayout = true + self.layoutTrafficLights(in: nextWindow) + } + } + layoutTrafficLights(in: nextWindow) + } + + func layout() { + guard let window else { return } + layoutTrafficLights(in: window) + } + + private func layoutTrafficLights(in window: NSWindow) { + guard !window.styleMask.contains(.fullScreen), + let titlebarHostView, + titlebarHostView.window === window else { + return + } + let customTitlebarTop = titlebarHostView.convert(titlebarHostView.bounds, to: nil).maxY + let desiredCenterY = customTitlebarTop - Self.titlebarHeight / 2 + for kind in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + guard let button = window.standardWindowButton(kind), + let container = button.superview else { continue } + let currentCenterY = container.convert(button.frame, to: nil).midY + let offset = desiredCenterY - currentCenterY + if abs(offset) > 0.25 { + button.frame.origin.y += offset + } + } + } + + private func stopObserving() { + let center = NotificationCenter.default + observers.forEach(center.removeObserver) + observers.removeAll() + } } final class WebViewController: NSViewController, WKNavigationDelegate, WKUIDelegate { private(set) var webView: WKWebView! let bridge = Bridge() private let errorLabel = NSTextField(labelWithString: "") + private let titlebarDragView = TitlebarDragView() + private let windowChrome = WindowChromeController() + private var pendingWindowTitle = "Home" - override func loadView() { view = NSView(frame: NSRect(x: 0, y: 0, width: 1100, height: 760)) } + override func loadView() { + let rootView = WorkbenchRootView(frame: NSRect(x: 0, y: 0, width: 1100, height: 760)) + rootView.wantsLayer = true + rootView.autoresizesSubviews = true + rootView.onLiveResizeChange = { [weak self] active in + self?.notifyWebContentOfLiveResize(active) + } + view = rootView + } override func viewDidLoad() { super.viewDidLoad() let configuration = WKWebViewConfiguration() configuration.setURLSchemeHandler(BundleSchemeHandler(), forURLScheme: "shellular") configuration.userContentController.add(bridge, name: "exec") - webView = WKWebView(frame: .zero, configuration: configuration) + guard let rootView = view as? WorkbenchRootView else { return } + rootView.onWorkbenchLayout = { [weak self] in + guard let self, let window = self.view.window else { return } + self.layoutTitlebarDragRegion(in: window) + } + let workbenchHost = rootView.workbenchHostView + webView = ShellularWorkbenchWebView(frame: workbenchHost.bounds, configuration: configuration) webView.navigationDelegate = self; webView.uiDelegate = self; webView.isInspectable = true - webView.setValue(false, forKey: "drawsBackground") - webView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(webView) - NSLayoutConstraint.activate([webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.topAnchor.constraint(equalTo: view.topAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor)]) + webView.underPageBackgroundColor = (view as? WorkbenchRootView)?.backgroundColor + rootView.attachWorkbenchContent(webView) + titlebarDragView.autoresizingMask = [.width, .minYMargin] + workbenchHost.addSubview(titlebarDragView, positioned: .above, relativeTo: webView) errorLabel.alignment = .center; errorLabel.maximumNumberOfLines = 4; errorLabel.isHidden = true - errorLabel.translatesAutoresizingMaskIntoConstraints = false; view.addSubview(errorLabel) - NSLayoutConstraint.activate([errorLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), errorLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 620)]) + errorLabel.translatesAutoresizingMaskIntoConstraints = false; workbenchHost.addSubview(errorLabel) + NSLayoutConstraint.activate([errorLabel.centerXAnchor.constraint(equalTo: workbenchHost.centerXAnchor), errorLabel.centerYAnchor.constraint(equalTo: workbenchHost.centerYAnchor), errorLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 620)]) bridge.setup(webView: webView, viewController: self) - webView.load(URLRequest(url: URL(string: "shellular://localhost/")!)) - NotificationCenter.default.addObserver(forName: .reloadWebView, object: nil, queue: .main) { [weak self] _ in self?.webView.reload() } - NotificationCenter.default.addObserver(forName: .openFile, object: nil, queue: .main) { [weak self] _ in - guard let self, let window = self.view.window else { return } - let panel = NSOpenPanel(); panel.allowsMultipleSelection = true - panel.beginSheetModal(for: window) { response in - guard response == .OK, - let data = try? JSONSerialization.data(withJSONObject: panel.urls.map(\.path)), - let json = String(data: data, encoding: .utf8) else { return } - self.webView.evaluateJavaScript("window.dispatchEvent(new CustomEvent('macosopenfiles',{detail:\(json)}))") + if (LocalCLIManager.shared.capability()["available"] as? Bool) == true { + LocalCLIManager.shared.ensureRunning { result in + if case .failure(let error) = result { + NSLog("Local CLI bootstrap failed: %@", error.localizedDescription) + } } } + webView.load(URLRequest(url: URL(string: "shellular://localhost/")!)) + NotificationCenter.default.addObserver(forName: .reloadWebView, object: nil, queue: .main) { [weak self] _ in self?.webView.reload() } NotificationCenter.default.addObserver(forName: NSApplication.didResignActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.webView.evaluateJavaScript("document.dispatchEvent(new CustomEvent('pause'))") } NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.webView.evaluateJavaScript("document.dispatchEvent(new CustomEvent('resume'))") } } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { errorLabel.isHidden = true } + override func viewDidAppear() { + super.viewDidAppear() + view.needsLayout = true + view.layoutSubtreeIfNeeded() + attach(to: view.window) + } + override func viewDidLayout() { + super.viewDidLayout() + windowChrome.layout() + if let window = view.window { + layoutTitlebarDragRegion(in: window) + } + } + private func attach(to window: NSWindow?) { + windowChrome.attach(to: window, titlebarHostView: view) + guard let window else { return } + window.title = pendingWindowTitle + if let rootView = view as? WorkbenchRootView { + window.backgroundColor = rootView.backgroundColor + } + layoutTitlebarDragRegion(in: window) + } + + private func layoutTitlebarDragRegion(in window: NSWindow) { + let trailingTrafficLightEdge = [ + NSWindow.ButtonType.closeButton, + .miniaturizeButton, + .zoomButton, + ].compactMap { kind -> CGFloat? in + guard let button = window.standardWindowButton(kind) else { return nil } + return view.convert(button.bounds, from: button).maxX + }.max() ?? 82 + let workbenchBounds = (view as? WorkbenchRootView)?.workbenchBounds ?? view.bounds + let frame = TitlebarDragRegion.frame( + in: workbenchBounds, + trafficLightTrailingEdge: trailingTrafficLightEdge, + height: WindowChromeController.titlebarHeight + ) + if titlebarDragView.frame != frame { + titlebarDragView.frame = frame + } + } + + private func notifyWebContentOfLiveResize(_ active: Bool) { + let event = active ? "shellular:native-resize-start" : "shellular:native-resize-end" + webView?.evaluateJavaScript("window.dispatchEvent(new Event('\(event)'))") + } + + func setTheme(_ theme: [String: Any]) { + let palette = BrowserChromePalette(theme) + guard let rootView = view as? WorkbenchRootView else { return } + rootView.backgroundColor = palette.primary + rootView.chromeBackgroundColor = palette.chromeBackground + rootView.browserSeparatorColor = palette.separator + webView?.underPageBackgroundColor = palette.primary + view.window?.backgroundColor = palette.primary + } + func setWindowTitle(_ rawTitle: String) { + let cleaned = rawTitle.unicodeScalars + .filter { !CharacterSet.controlCharacters.contains($0) } + .prefix(160) + let title = String(cleaned.map { Character($0) }).trimmingCharacters(in: .whitespacesAndNewlines) + pendingWindowTitle = title.isEmpty ? "Home" : title + view.window?.title = pendingWindowTitle + } + + func showBrowserSidebar(_ browserView: NSView) { + guard let rootView = view as? WorkbenchRootView else { return } + rootView.showBrowserSidebar(browserView) + view.needsLayout = true + } + + func hideBrowserSidebar() { + (view as? WorkbenchRootView)?.hideBrowserSidebar() + view.needsLayout = true + } + + var isBrowserSidebarVisible: Bool { + (view as? WorkbenchRootView)?.browserSidebarVisible ?? false + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + errorLabel.isHidden = true + } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { show(error) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { show(error) } private func show(_ error: Error) { errorLabel.stringValue = "Shellular could not load its interface.\n\(error.localizedDescription)"; errorLabel.isHidden = false } diff --git a/platforms/macos/shellular/shellular.direct.entitlements b/platforms/macos/shellular/shellular.direct.entitlements new file mode 100644 index 0000000..7fb1618 --- /dev/null +++ b/platforms/macos/shellular/shellular.direct.entitlements @@ -0,0 +1,8 @@ + + + +com.apple.security.device.camera +com.apple.security.device.microphone +com.apple.security.network.client +com.apple.security.network.server + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f681cd..a59d4fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,15 @@ importers: '@codemirror/view': specifier: ^6.41.1 version: 6.41.1 + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.5) '@headlessui/react': specifier: ^2.2.10 version: 2.2.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -56,9 +65,12 @@ importers: '@pierre/diffs': specifier: ^1.1.20 version: 1.1.20(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@pierre/trees': + specifier: 1.0.0-beta.5 + version: 1.0.0-beta.5(@shikijs/themes@3.23.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@3.23.0) '@shellular/protocol': - specifier: ^0.0.26 - version: 0.0.26 + specifier: ^0.0.29 + version: 0.0.29 '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -107,6 +119,9 @@ importers: marked: specifier: ^18.0.2 version: 18.0.2 + monaco-editor: + specifier: 0.55.1 + version: 0.55.1 nanoid: specifier: ^5.1.11 version: 5.1.11 @@ -131,19 +146,19 @@ importers: devDependencies: '@babel/core': specifier: ^7.29.0 - version: 7.29.0 + version: 7.29.0(supports-color@8.1.1) '@babel/plugin-transform-runtime': specifier: ^7.29.0 - version: 7.29.0(@babel/core@7.29.0) + version: 7.29.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/preset-env': specifier: ^7.29.2 - version: 7.29.2(@babel/core@7.29.0) + version: 7.29.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/preset-react': specifier: ^7.28.5 - version: 7.28.5(@babel/core@7.29.0) + version: 7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/preset-typescript': specifier: ^7.28.5 - version: 7.28.5(@babel/core@7.29.0) + version: 7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/runtime': specifier: ^7.29.2 version: 7.29.2 @@ -176,7 +191,7 @@ importers: version: 10.5.0(postcss@8.5.12) babel-loader: specifier: ^10.1.1 - version: 10.1.1(@babel/core@7.29.0)(webpack@5.106.2) + version: 10.1.1(@babel/core@7.29.0(supports-color@8.1.1))(webpack@5.106.2) baseline-browser-mapping: specifier: ^2.10.24 version: 2.10.24 @@ -192,6 +207,9 @@ importers: mini-css-extract-plugin: specifier: ^2.10.2 version: 2.10.2(webpack@5.106.2) + monaco-editor-webpack-plugin: + specifier: 7.1.1 + version: 7.1.1(monaco-editor@0.55.1)(webpack@5.106.2) postcss: specifier: ^8.5.12 version: 8.5.12 @@ -203,7 +221,7 @@ importers: version: 4.0.2(webpack@5.106.2) react-compiler-webpack: specifier: ^1.0.0 - version: 1.0.0(babel-plugin-react-compiler@1.0.0) + version: 1.0.0(babel-plugin-react-compiler@1.0.0)(supports-color@8.1.1) react-refresh: specifier: ^0.14.2 version: 0.14.2 @@ -236,7 +254,7 @@ importers: version: 6.0.1(webpack-dev-server@5.2.3)(webpack@5.106.2) webpack-dev-server: specifier: ^5.2.3 - version: 5.2.3(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.106.2) + version: 5.2.3(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.106.2) xml2js: specifier: ^0.6.2 version: 0.6.2 @@ -246,8 +264,8 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - '@agentclientprotocol/sdk@0.26.0': - resolution: {integrity: sha512-ialrcI+RzKOYe+fw+TfpyTdRmEoqIkXLlwbTi6XgaXXfdhNcdod7TmE1VsTnG3yTlox8TMTSMQgWbLLbz3r86Q==} + '@agentclientprotocol/sdk@1.2.1': + resolution: {integrity: sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1019,6 +1037,28 @@ packages: resolution: {integrity: sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==} engines: {node: '>=14.17.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -1424,6 +1464,32 @@ packages: resolution: {integrity: sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw==} engines: {vscode: ^1.0.0} + '@pierre/theming@0.0.2': + resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==} + peerDependencies: + '@pierre/theme': ^1.1.0 + '@shikijs/themes': ^3.0.0 || ^4.0.0 + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + shiki: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + '@pierre/theme': + optional: true + '@shikijs/themes': + optional: true + react: + optional: true + react-dom: + optional: true + shiki: + optional: true + + '@pierre/trees@1.0.0-beta.5': + resolution: {integrity: sha512-IzxkB9qv6GLbeEXObhlAD205LfYHiLeRwJdnaIdX0f5keTZF4X9EfiuEQ3QiyxOxouVVmUX3rX7m6a8zNMo/wA==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + '@pmmmwh/react-refresh-webpack-plugin@0.5.17': resolution: {integrity: sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==} engines: {node: '>= 10.13'} @@ -1565,8 +1631,8 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@shellular/protocol@0.0.26': - resolution: {integrity: sha512-97HZ6fVVVr1BK8uxVQpzHRkDRL4uo2naThYoMDghJPmnMwgSzCElC0e8WzLS+7VW/2x+zlzSFyi67B/1PIkhuA==} + '@shellular/protocol@0.0.29': + resolution: {integrity: sha512-xM+wsbMPgHhoJ8nMPNYiaM10j5UTUeh8T9uu4xMafml7WiROPocxOuJJ2Veh7CUcXpT/SOZMDzfzY8zzozaMRw==} '@shikijs/core@3.23.0': resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} @@ -2386,6 +2452,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dompurify@3.4.1: resolution: {integrity: sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==} @@ -2960,6 +3029,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@18.0.2: resolution: {integrity: sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==} engines: {node: '>= 20'} @@ -3047,6 +3121,15 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + monaco-editor-webpack-plugin@7.1.1: + resolution: {integrity: sha512-WxdbFHS3Wtz4V9hzhe/Xog5hQRSMxmDLkEEYZwqMDHgJlkZo00HVFZR0j5d0nKypjTUkkygH3dDSXERLG4757A==} + peerDependencies: + monaco-editor: '>= 0.31.0' + webpack: ^4.5.0 || 5.x + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + motion-dom@12.38.0: resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} @@ -3255,6 +3338,14 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@6.6.5: + resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==} + peerDependencies: + preact: '>=10 || >= 11.0.0-0' + + preact@11.0.0-beta.0: + resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -4062,7 +4153,7 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@agentclientprotocol/sdk@0.26.0(zod@4.3.6)': + '@agentclientprotocol/sdk@1.2.1(zod@4.3.6)': dependencies: zod: 4.3.6 @@ -4096,20 +4187,20 @@ snapshots: '@babel/compat-data@7.29.0': {} - '@babel/core@7.29.0': + '@babel/core@7.29.0(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helpers': 7.29.2 '@babel/parser': 7.29.2 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4136,32 +4227,32 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@8.1.1) '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@8.1.1) + '@babel/traverse': 7.29.0(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) lodash.debounce: 4.0.8 resolve: 1.22.12 transitivePeerDependencies: @@ -4169,26 +4260,26 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-member-expression-to-functions@7.28.5': + '@babel/helper-member-expression-to-functions@7.28.5(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.28.6(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-module-imports': 7.28.6(supports-color@8.1.1) '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -4198,27 +4289,27 @@ snapshots: '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-wrap-function': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/helper-wrap-function': 7.28.6(supports-color@8.1.1) + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@8.1.1) '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + '@babel/helper-skip-transparent-expression-wrappers@7.27.1(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -4229,10 +4320,10 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helper-wrap-function@7.28.6': + '@babel/helper-wrap-function@7.28.6(supports-color@8.1.1)': dependencies: '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -4246,559 +4337,559 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@8.1.1) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) - '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) - '@babel/traverse': 7.29.0 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-module-imports': 7.28.6(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-globals': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/traverse': 7.29.0 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/template': 7.28.6 - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) - '@babel/traverse': 7.29.0 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-module-imports': 7.28.6 + '@babel/helper-module-imports': 7.28.6(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-module-imports': 7.28.6(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@8.1.1) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/preset-env@7.29.2(@babel/core@7.29.0)': + '@babel/preset-env@7.29.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/compat-data': 7.29.0 - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) - '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) - babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.0) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0(supports-color@8.1.1)) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) core-js-compat: 3.49.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/types': 7.29.0 esutils: 2.0.3 - '@babel/preset-react@7.28.5(@babel/core@7.29.0)': + '@babel/preset-react@7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -4810,7 +4901,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/types': 7.29.0 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.0(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 @@ -4818,7 +4909,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -5151,6 +5242,31 @@ snapshots: '@discoveryjs/json-ext@0.6.3': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.5)': + dependencies: + react: 19.2.5 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.5) + '@dnd-kit/utilities': 3.2.2(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@dnd-kit/utilities': 3.2.2(react@19.2.5) + react: 19.2.5 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.5)': + dependencies: + react: 19.2.5 + tslib: 2.8.1 + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -5649,6 +5765,25 @@ snapshots: '@pierre/theme@0.0.28': {} + '@pierre/theming@0.0.2(@shikijs/themes@3.23.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@3.23.0)': + optionalDependencies: + '@shikijs/themes': 3.23.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + shiki: 3.23.0 + + '@pierre/trees@1.0.0-beta.5(@shikijs/themes@3.23.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@3.23.0)': + dependencies: + '@pierre/theming': 0.0.2(@shikijs/themes@3.23.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@3.23.0) + preact: 11.0.0-beta.0 + preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + transitivePeerDependencies: + - '@pierre/theme' + - '@shikijs/themes' + - shiki + '@pmmmwh/react-refresh-webpack-plugin@0.5.17(react-refresh@0.14.2)(webpack-dev-server@5.2.3)(webpack@5.106.2)': dependencies: ansi-html: 0.0.9 @@ -5661,7 +5796,7 @@ snapshots: source-map: 0.7.6 webpack: 5.106.2(webpack-cli@6.0.1) optionalDependencies: - webpack-dev-server: 5.2.3(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.106.2) + webpack-dev-server: 5.2.3(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.106.2) '@react-aria/focus@3.22.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: @@ -5733,9 +5868,9 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@shellular/protocol@0.0.26': + '@shellular/protocol@0.0.29': dependencies: - '@agentclientprotocol/sdk': 0.26.0(zod@4.3.6) + '@agentclientprotocol/sdk': 1.2.1(zod@4.3.6) zod: 4.3.6 '@shikijs/core@3.23.0': @@ -6151,7 +6286,7 @@ snapshots: webpack: 5.106.2(webpack-cli@6.0.1) webpack-cli: 6.0.1(webpack-dev-server@5.2.3)(webpack@5.106.2) optionalDependencies: - webpack-dev-server: 5.2.3(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.106.2) + webpack-dev-server: 5.2.3(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.106.2) '@xterm/addon-fit@0.11.0': {} @@ -6253,42 +6388,42 @@ snapshots: autosize@6.0.1: {} - babel-loader@10.1.1(@babel/core@7.29.0)(webpack@5.106.2): + babel-loader@10.1.1(@babel/core@7.29.0(supports-color@8.1.1))(webpack@5.106.2): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@8.1.1) find-up: 5.0.0 optionalDependencies: webpack: 5.106.2(webpack-cli@6.0.1) - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@babel/compat-data': 7.29.0 - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.0): + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -6312,11 +6447,11 @@ snapshots: binary-extensions@2.3.0: {} - body-parser@1.20.5: + body-parser@1.20.5(supports-color@8.1.1): dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) depd: 2.0.0 destroy: 1.2.0 http-errors: 2.0.1 @@ -6450,11 +6585,11 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.8.1(supports-color@8.1.1): dependencies: bytes: 3.1.2 compressible: 2.0.18 - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -6534,13 +6669,17 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - debug@2.6.9: + debug@2.6.9(supports-color@8.1.1): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 8.1.1 - debug@4.4.3: + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 decimal.js@10.6.0: {} @@ -6579,6 +6718,10 @@ snapshots: dom-accessibility-api@0.6.3: {} + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dompurify@3.4.1: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -6659,21 +6802,21 @@ snapshots: expect-type@1.4.0: {} - express@4.22.1: + express@4.22.1(supports-color@8.1.1): dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.5 + body-parser: 1.20.5(supports-color@8.1.1) content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.0.7 - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.3.2 + finalhandler: 1.3.2(supports-color@8.1.1) fresh: 0.5.2 http-errors: 2.0.1 merge-descriptors: 1.0.3 @@ -6685,8 +6828,8 @@ snapshots: qs: 6.14.2 range-parser: 1.2.1 safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 + send: 0.19.2(supports-color@8.1.1) + serve-static: 1.16.3(supports-color@8.1.1) setprototypeof: 1.2.0 statuses: 2.0.2 type-is: 1.6.18 @@ -6715,9 +6858,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.2: + finalhandler@1.3.2(supports-color@8.1.1): dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -6739,7 +6882,9 @@ snapshots: flat@5.0.2: {} - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@8.1.1)): + optionalDependencies: + debug: 4.4.3(supports-color@8.1.1) forwarded@0.2.0: {} @@ -6861,10 +7006,10 @@ snapshots: http-parser-js@0.5.10: {} - http-proxy-middleware@2.0.9(@types/express@4.17.25): + http-proxy-middleware@2.0.9(@types/express@4.17.25)(debug@4.4.3(supports-color@8.1.1)): dependencies: '@types/http-proxy': 1.17.17 - http-proxy: 1.18.1 + http-proxy: 1.18.1(debug@4.4.3(supports-color@8.1.1)) is-glob: 4.0.3 is-plain-obj: 3.0.0 micromatch: 4.0.8 @@ -6873,10 +7018,10 @@ snapshots: transitivePeerDependencies: - debug - http-proxy@1.18.1: + http-proxy@1.18.1(debug@4.4.3(supports-color@8.1.1)): dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1)) requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -7107,6 +7252,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@14.0.0: {} + marked@18.0.2: {} math-intrinsics@1.1.0: {} @@ -7196,6 +7343,17 @@ snapshots: minimalistic-assert@1.0.1: {} + monaco-editor-webpack-plugin@7.1.1(monaco-editor@0.55.1)(webpack@5.106.2): + dependencies: + loader-utils: 2.0.4 + monaco-editor: 0.55.1 + webpack: 5.106.2(webpack-cli@6.0.1) + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + motion-dom@12.38.0: dependencies: motion-utils: 12.36.0 @@ -7378,6 +7536,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@6.6.5(preact@11.0.0-beta.0): + dependencies: + preact: 11.0.0-beta.0 + + preact@11.0.0-beta.0: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -7443,11 +7607,11 @@ snapshots: chart.js: 4.5.1 react: 19.2.5 - react-compiler-webpack@1.0.0(babel-plugin-react-compiler@1.0.0): + react-compiler-webpack@1.0.0(babel-plugin-react-compiler@1.0.0)(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) babel-plugin-react-compiler: 1.0.0 transitivePeerDependencies: - supports-color @@ -7636,9 +7800,9 @@ snapshots: semver@7.7.4: {} - send@0.19.2: + send@0.19.2(supports-color@8.1.1): dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) depd: 2.0.0 destroy: 1.2.0 encodeurl: 2.0.0 @@ -7654,11 +7818,11 @@ snapshots: transitivePeerDependencies: - supports-color - serve-index@1.9.2: + serve-index@1.9.2(supports-color@8.1.1): dependencies: accepts: 1.3.8 batch: 0.6.1 - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) escape-html: 1.0.3 http-errors: 1.8.1 mime-types: 2.1.35 @@ -7666,12 +7830,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.16.3: + serve-static@1.16.3(supports-color@8.1.1): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.19.2 + send: 0.19.2(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7749,9 +7913,9 @@ snapshots: space-separated-tokens@2.0.2: {} - spdy-transport@3.0.0: + spdy-transport@3.0.0(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) detect-node: 2.1.0 hpack.js: 2.1.6 obuf: 1.1.2 @@ -7760,13 +7924,13 @@ snapshots: transitivePeerDependencies: - supports-color - spdy@4.0.2: + spdy@4.0.2(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) handle-thing: 2.0.1 http-deceiver: 1.2.7 select-hose: 2.0.0 - spdy-transport: 3.0.0 + spdy-transport: 3.0.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -8052,7 +8216,7 @@ snapshots: webpack: 5.106.2(webpack-cli@6.0.1) webpack-merge: 6.0.1 optionalDependencies: - webpack-dev-server: 5.2.3(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.106.2) + webpack-dev-server: 5.2.3(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.106.2) webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.106.2): dependencies: @@ -8067,7 +8231,7 @@ snapshots: transitivePeerDependencies: - tslib - webpack-dev-server@5.2.3(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.106.2): + webpack-dev-server@5.2.3(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.106.2): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -8081,20 +8245,20 @@ snapshots: bonjour-service: 1.3.0 chokidar: 3.6.0 colorette: 2.0.20 - compression: 1.8.1 + compression: 1.8.1(supports-color@8.1.1) connect-history-api-fallback: 2.0.0 - express: 4.22.1 + express: 4.22.1(supports-color@8.1.1) graceful-fs: 4.2.11 - http-proxy-middleware: 2.0.9(@types/express@4.17.25) + http-proxy-middleware: 2.0.9(@types/express@4.17.25)(debug@4.4.3(supports-color@8.1.1)) ipaddr.js: 2.3.0 launch-editor: 2.13.2 open: 10.2.0 p-retry: 6.2.1 schema-utils: 4.3.3 selfsigned: 5.5.0 - serve-index: 1.9.2 + serve-index: 1.9.2(supports-color@8.1.1) sockjs: 0.3.24 - spdy: 4.0.2 + spdy: 4.0.2(supports-color@8.1.1) webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.106.2) ws: 8.20.0 optionalDependencies: diff --git a/src/App.scss b/src/App.scss index d09bc6c..1ca12ee 100644 --- a/src/App.scss +++ b/src/App.scss @@ -11,6 +11,36 @@ } } +.page-overlay.desktop-page-dialog-overlay { + position: fixed; + inset: 0; + z-index: 70; + display: flex; + align-items: center; + justify-content: center; + padding: 28px; + background: transparent; + + .desktop-pushed-page-shell { + position: relative; + width: min(1040px, calc(100vw - 56px)); + height: min(760px, calc(100vh - 56px)); + min-width: min(560px, calc(100vw - 56px)); + min-height: min(420px, calc(100vh - 56px)); + overflow: hidden; + border: 1px solid var(--card-border); + border-radius: 10px; + background: var(--primary); + box-shadow: 0 24px 80px var(--shadow-color); + + > .page { + position: absolute; + inset: 0; + animation: none; + } + } +} + .tab-view { position: fixed; inset: 0; @@ -43,7 +73,7 @@ margin-left: calc(var(--sidebar-w) + var(--sat, 0px)); } - @include browser { + @include desktop-ui { margin-left: var(--sidebar-w); } } @@ -91,7 +121,7 @@ padding: calc(var(--sat, 0px) + 16px) 8px calc(var(--sab, 0px) + 16px) calc(8px + var(--sat, 0px)); } - @include browser { + @include desktop-ui { width: var(--sidebar-w); padding: 32px 0; } @@ -171,4 +201,4 @@ opacity: 0.6; transform: scale(1.15); } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index 9feb7d0..9ca2e92 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import ConnectionStatus from "components/ConnectionStatus"; import EmptyState from "components/EmptyState"; import actionStack from "lib/actionStack"; import { AuthProvider, useAuth } from "lib/auth"; +import { ONBOARDING_KEY, resolveOnboardingVisibility } from "lib/onboarding"; import * as store from "lib/store"; import LoginPage from "pages/login"; import OnboardingPage from "pages/onboarding"; @@ -18,12 +19,15 @@ import { useEffect, useRef, useState, + useSyncExternalStore, } from "react"; import { ShellularProvider } from "state"; import { getHasAnyStreaming, listenToSessionStreamingEvent, } from "state/sessions"; +import DesktopShell from "workbench/DesktopShell"; +import { getWorkbenchSnapshot, subscribeWorkbench } from "workbench/store"; type TabId = "home" | "terminals" | "projects" | "agents" | "more" | "browser"; @@ -40,7 +44,6 @@ interface PageStackEntry { showConnectionBanner: boolean; } -export const ONBOARDING_KEY = "shellular:onboarding-complete"; const PAGE_HIDE_DURATION = 240; const TABS: Tab[] = [ { id: "home", label: "Home", icon: "home" }, @@ -63,7 +66,7 @@ const TABS: Tab[] = [ id: "browser", label: "Browser", icon: "globe", - disabled: process.env.IS_BROWSER, + disabled: process.env.IS_DESKTOP_UI, }, { id: "more", @@ -88,14 +91,16 @@ let tabViewHidden = false; let handleTabChange: (tab: TabId) => void; export default function App() { + const [authKey, setAuthKey] = useState(0); + return ( - - + + setAuthKey((key) => key + 1)} /> ); } -function AuthGate() { +function AuthGate({ onReloadLogin }: { onReloadLogin: () => void }) { const { status } = useAuth(); if (status === "loading") { @@ -103,7 +108,7 @@ function AuthGate() { } if (status === "unauthenticated") { - return ; + return ; } return ; @@ -113,11 +118,16 @@ function AuthenticatedApp() { const [pageStack, setPageStack] = useState([]); const [closingIds, setClosingIds] = useState>(new Set()); const [showOnboarding, setShowOnboarding] = useState(null); + const workbench = useSyncExternalStore( + subscribeWorkbench, + getWorkbenchSnapshot, + ); useEffect(() => { - store.get(ONBOARDING_KEY).then((val) => { - setShowOnboarding(!val); - }); + resolveOnboardingVisibility({ + isMacos: process.env.IS_MACOS, + readCompletion: () => store.get(ONBOARDING_KEY), + }).then(setShowOnboarding); }, []); const handleOnboardingComplete = useCallback(() => { @@ -175,24 +185,41 @@ function AuthenticatedApp() { // tabs and pushed pages alike. A pushed page can opt out (e.g. flows that // manage their own connection UI) via showConnectionBanner: false, which // only applies while that page is the top-most one. + const activeWorkbenchSurface = workbench.surfaces.find( + (surface) => surface.id === workbench.activeId, + ); + const shellShowsConnectionOverlay = process.env.IS_DESKTOP_UI + ? (workbench.dialog ?? activeWorkbenchSurface)?.showConnectionBanner !== + false + : true; const showConnectionOverlay = topNonClosingPage ? topNonClosingPage.showConnectionBanner - : true; + : shellShowsConnectionOverlay; return ( - + {process.env.IS_DESKTOP_UI ? : } {pageStack.map(({ id, element }) => { const isClosing = closingIds.has(id); const isVisible = id === topNonClosingPage?.id || isClosing; + const overlayClass = `page-overlay${isClosing ? " closing" : ""}${process.env.IS_DESKTOP_UI ? " desktop-page-dialog-overlay" : ""}`; return (
{ + if (!process.env.IS_DESKTOP_UI) return; + if (event.target !== event.currentTarget) return; + closePage(id); + }} > - {element} + {process.env.IS_DESKTOP_UI ? ( +
{element}
+ ) : ( + element + )}
); })} @@ -201,7 +228,7 @@ function AuthenticatedApp() { ); } -function TabView() { +function MobileShell() { const [activeTab, setActiveTab] = useState(currentTab); const prevTabRef = useRef(currentTab); const TabContent = TABS_MAP[activeTab]; diff --git a/src/bridge/browser.test.ts b/src/bridge/browser.test.ts new file mode 100644 index 0000000..ab7f7b2 --- /dev/null +++ b/src/bridge/browser.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + call: vi.fn(async () => undefined), + snapshot: { + connectionStatus: "connected", + transport: "local", + hostInfo: { + id: "host-1", + hostname: "This Mac", + capabilities: undefined as { tcpTunnel?: 1 } | undefined, + }, + }, + theme: { json: { primary: "#101010" } as Record }, +})); + +vi.mock("./bridge", () => ({ + default: () => mocks.call, +})); +vi.mock("state/connection", () => ({ + getConnectionSnapshot: () => mocks.snapshot, +})); +vi.mock("themes", () => ({ + default: { + get current() { + return mocks.theme; + }, + }, +})); +vi.mock("../browser/homeDocument", () => ({ + getBrowserHomeDocument: () => "Home", +})); + +import browser from "./browser"; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.snapshot.transport = "local"; + mocks.snapshot.hostInfo.capabilities = undefined; + mocks.theme.json = { primary: "#101010" }; +}); + +describe("native browser bridge", () => { + it("passes a ready Home document when opening the browser", async () => { + await browser.open(); + + expect(mocks.call).toHaveBeenCalledWith("open", [ + undefined, + { primary: "#101010" }, + { + status: "connected", + transport: "local", + hostId: "host-1", + hostName: "This Mac", + }, + "Home", + ]); + }); + + it("refreshes the cached Home document with native theme updates", async () => { + mocks.theme.json = { + primary: "#202020", + surfaceSoft: "#303030", + lineSoft: "#404040", + }; + await browser.syncTheme(); + + expect(mocks.call).toHaveBeenCalledWith("setTheme", [ + { + primary: "#202020", + surfaceSoft: "#303030", + lineSoft: "#404040", + }, + "Home", + ]); + }); + + it("advertises remote TCP tunnel support to the native browser", async () => { + mocks.snapshot.transport = "remote"; + mocks.snapshot.hostInfo.capabilities = { tcpTunnel: 1 }; + + await browser.syncConnectionContext(); + + expect(mocks.call).toHaveBeenCalledWith("setContext", [ + { + status: "connected", + transport: "remote", + hostId: "host-1", + hostName: "This Mac", + tcpTunnelVersion: 1, + }, + ]); + }); +}); diff --git a/src/bridge/browser.ts b/src/bridge/browser.ts index 743bc7d..d8b67c6 100644 --- a/src/bridge/browser.ts +++ b/src/bridge/browser.ts @@ -1,4 +1,6 @@ +import { getConnectionSnapshot } from "state/connection"; import themes from "themes"; +import { getBrowserHomeDocument } from "../browser/homeDocument"; import bridge from "./bridge"; export type AuthResult = { @@ -8,17 +10,59 @@ export type AuthResult = { const browser = bridge("Browser"); +export type BrowserConnectionContext = { + status: "disconnected" | "connecting" | "connected" | "reconnecting"; + transport: "local" | "remote" | null; + hostId?: string; + hostName?: string; + tcpTunnelVersion?: 1; +}; + +function connectionContext(): BrowserConnectionContext { + const snapshot = getConnectionSnapshot(); + return { + status: snapshot.connectionStatus, + transport: snapshot.transport, + hostId: snapshot.hostInfo?.id, + hostName: snapshot.hostInfo?.hostname, + tcpTunnelVersion: snapshot.hostInfo?.capabilities?.tcpTunnel, + }; +} + export default { open(url?: string): Promise { const t = (themes.current?.json as Record | undefined) ?? {}; - return browser("open", [url, t]) as Promise; + return browser("open", [ + url, + t, + connectionContext(), + getBrowserHomeDocument(), + ]) as Promise; }, openHTML(html: string): Promise { const t = (themes.current?.json as Record | undefined) ?? {}; - return browser("openHTML", [html, t]) as Promise; + return browser("openHTML", [ + html, + t, + connectionContext(), + getBrowserHomeDocument(), + ]) as Promise; + }, + + syncConnectionContext(): Promise { + return browser("setContext", [connectionContext()]) as Promise; + }, + + syncTheme(): Promise { + const theme = + (themes.current?.json as Record | undefined) ?? {}; + return browser("setTheme", [ + theme, + getBrowserHomeDocument(), + ]) as Promise; }, async openForAuth( diff --git a/src/bridge/contextMenu.ts b/src/bridge/contextMenu.ts new file mode 100644 index 0000000..0cb959c --- /dev/null +++ b/src/bridge/contextMenu.ts @@ -0,0 +1,23 @@ +import type { NativeContextMenuViewport } from "context-menu/native"; +import type { + ContextMenuAnchor, + ContextMenuTrigger, + ResolvedMenuItem, +} from "context-menu/types"; +import bridge from "./bridge"; + +const contextMenu = bridge("ContextMenu"); + +export function showNativeContextMenu(request: { + id: number; + trigger: ContextMenuTrigger; + anchor: ContextMenuAnchor; + viewport: NativeContextMenuViewport; + items: ResolvedMenuItem[]; +}): Promise { + return contextMenu("show", [request]) as Promise; +} + +export function cancelNativeContextMenu(): Promise { + return contextMenu("cancel") as Promise; +} diff --git a/src/bridge/dialog.ts b/src/bridge/dialog.ts index aba8911..05be39a 100644 --- a/src/bridge/dialog.ts +++ b/src/bridge/dialog.ts @@ -1,4 +1,4 @@ -import { openAppDialog } from "components/AppDialog"; +import { type AppDialogOption, openAppDialog } from "components/AppDialog"; export default { message(message: string, title = ""): Promise { @@ -14,6 +14,38 @@ export default { ): Promise { return openAppDialog("prompt", { message, defaultValue, title }); }, + select( + message: string, + options: AppDialogOption[], + title = "", + defaultValue = "", + ): Promise { + return openAppDialog("select", { + message, + options, + title, + defaultValue, + }); + }, + async selectProjectFile( + message: string, + options: AppDialogOption[], + title = "New File", + ): Promise<{ projectPath: string; fileName: string } | null> { + const result = await openAppDialog("select-text", { + message, + options, + title, + selectLabel: "Project", + textLabel: "Filename", + }); + return result + ? { + projectPath: result.selectedValue, + fileName: result.textValue, + } + : null; + }, alert(message: string, title = ""): Promise { return this.message(message, title); }, diff --git a/src/bridge/localCli.ts b/src/bridge/localCli.ts new file mode 100644 index 0000000..2818a51 --- /dev/null +++ b/src/bridge/localCli.ts @@ -0,0 +1,73 @@ +import type { AuthedClientInfo } from "@shellular/protocol"; +import bridge from "./bridge"; + +export type LocalCliClientMutation = + | { action: "set-approval"; clientId: string; approved: boolean } + | { action: "delete"; clientId: string }; +export type LocalCliLogEntry = { + id: number; + timestamp: string; + level: "log" | "debug" | "warn" | "error"; + message: string; +}; +export type LocalCliClient = { + clientId: string; + platform: string; + appVersion: string; + deviceModel?: string; + approved: boolean; + connected: boolean; + firstSeen: string; + lastSeen: string; +}; +export type LocalCliSnapshot = { + state: string; + cliVersion?: string; + protocolVersion?: number; + pid?: number; + port?: number; + uptimeMs?: number; + directory?: string; + machineName?: string; + source?: string; + lifecycle?: "app-owned" | "attached"; + remoteState?: string; + qrData?: string; + clients: LocalCliClient[]; + logs: LocalCliLogEntry[]; +}; +export type LocalCliTicketResponse = { + wsUrl: string; + ticket: string; + hostId: string; + clientId: string; + encryptionKey: string; + protocolVersion: number; +}; +export type LocalCliTicketClient = Omit< + AuthedClientInfo, + "hostId" | "platform" +> & { platform: "macos" }; + +const local = bridge("LocalCLI"); + +export type LocalCliCapability = { + available: boolean; + sandboxed: boolean; + protocolVersion: number; +}; + +export default { + capability: () => local("capability") as Promise, + ensureRunning: () => local("ensureRunning") as Promise, + status: () => local("status") as Promise, + ticket: (client: LocalCliTicketClient) => + local("ticket", [ + { protocolVersion: 1, client }, + ]) as Promise, + mutateClient: (mutation: LocalCliClientMutation) => + local("mutateClient", [mutation]) as Promise<{ success: boolean }>, + stop: () => local("stop") as Promise<{ success: boolean }>, + disable: () => local("disable") as Promise<{ success: boolean }>, + qrCode: (data: string) => local("qrCode", [data]) as Promise, +}; diff --git a/src/bridge/native.test.ts b/src/bridge/native.test.ts new file mode 100644 index 0000000..c5ccebd --- /dev/null +++ b/src/bridge/native.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const calls = vi.hoisted( + () => + [] as Array<{ + success: (value: unknown) => void; + action: string; + args: unknown[]; + }>, +); + +beforeEach(() => { + calls.length = 0; + vi.stubGlobal("Bridge", { + exec( + success: (value: unknown) => void, + _error: (error: Error) => void, + _service: string, + action: string, + args: unknown[], + ) { + calls.push({ success, action, args }); + }, + }); +}); + +import native from "./native"; + +describe("desktop command bridge", () => { + it("uses tokenized registrations and ignores events after disposal", () => { + const handler = vi.fn(); + const dispose = native.setDesktopCommandHandler(handler); + const registration = calls[0]; + expect(registration?.action).toBe("setDesktopCommandHandler"); + expect(registration?.args[0]).toMatch(/^desktop-command-/); + + for (const command of [ + "open-folder", + "new-file", + "show-explorer", + "project-search", + "show-source-control", + "toggle-terminal", + "open-keyboard-shortcuts", + ]) { + registration?.success(command); + } + expect(handler.mock.calls.map(([command]) => command)).toEqual([ + "open-folder", + "new-file", + "show-explorer", + "project-search", + "show-source-control", + "toggle-terminal", + "open-keyboard-shortcuts", + ]); + registration?.success(null); + expect(handler).toHaveBeenCalledTimes(7); + + dispose(); + expect(calls[1]).toMatchObject({ + action: "clearDesktopCommandHandler", + args: registration?.args, + }); + registration?.success("open-folder"); + expect(handler).toHaveBeenCalledTimes(7); + }); + + it("sends contextual and resolved shortcuts to the native menu", () => { + void native.setDesktopShortcutContext({ + contextualNew: "new-chat", + shortcuts: { + "contextual-new": { key: "n", modifiers: ["meta"] }, + "toggle-terminal": { key: "`", modifiers: ["ctrl"] }, + "open-folder": null, + }, + }); + expect(calls[0]).toMatchObject({ + action: "setDesktopShortcutContext", + args: [ + { + contextualNew: "new-chat", + shortcuts: { + "contextual-new": { key: "n", modifiers: ["meta"] }, + "toggle-terminal": { key: "`", modifiers: ["ctrl"] }, + "open-folder": null, + }, + }, + ], + }); + }); +}); diff --git a/src/bridge/native.ts b/src/bridge/native.ts index f830a06..9884a68 100644 --- a/src/bridge/native.ts +++ b/src/bridge/native.ts @@ -1,5 +1,10 @@ import type Theme from "classes/theme"; import permissions from "lib/permissions"; +import { + DESKTOP_COMMAND_IDS, + type DesktopKeyboardCommand, + type ShortcutModifier, +} from "workbench/desktopShortcuts"; import bridge from "./bridge"; export type AppInfo = { @@ -31,7 +36,27 @@ export type NavigationMode = { statusBarHeight: number; }; +export type DesktopCommand = DesktopKeyboardCommand; + +export type DesktopCapabilities = { + localWorkspace: boolean; + canPickLocalFiles: boolean; + canRevealLocalPath: boolean; + canOpenSystemTerminal: boolean; +}; + +export type DesktopNativeShortcut = { + key: string; + modifiers: ShortcutModifier[]; +}; + +export type DesktopShortcutContext = { + contextualNew: "new-chat" | "new-file"; + shortcuts?: Partial>; +}; + const native = bridge("Native"); +let desktopCommandRegistration = 0; export default { exitApp(): Promise { @@ -77,6 +102,62 @@ export default { [], ); }, + setDesktopCommandHandler( + handler: (command: DesktopCommand) => void, + onerror: (e: Error) => void = console.error, + ) { + desktopCommandRegistration += 1; + const registrationId = `desktop-command-${desktopCommandRegistration}`; + let active = true; + Bridge.exec( + (data) => { + if (active && isDesktopCommand(data)) handler(data); + }, + onerror, + "Native", + "setDesktopCommandHandler", + [registrationId], + ); + return () => { + if (!active) return; + active = false; + void native("clearDesktopCommandHandler", [registrationId]).catch( + onerror, + ); + }; + }, + getDesktopCapabilities(): Promise { + return native("getDesktopCapabilities") as Promise; + }, + pickLocalFiles(rootPath?: string): Promise { + return native("pickLocalFiles", rootPath ? [rootPath] : []) as Promise< + string[] + >; + }, + pickLocalDirectory(rootPath: string): Promise { + return native("pickLocalDirectory", [rootPath]) as Promise; + }, + revealLocalPath(path: string): Promise { + return native("revealLocalPath", [path]) as Promise; + }, + openSystemTerminal(path: string): Promise { + return native("openSystemTerminal", [path]) as Promise; + }, + setWindowTitle(title: string): Promise { + return native("setWindowTitle", [title]) as Promise; + }, + setDesktopShortcutContext(context: DesktopShortcutContext): Promise { + return native("setDesktopShortcutContext", [context]) as Promise; + }, + loadBundledAsset(path: string): Promise { + return native("loadBundledAsset", [path]) as Promise; + }, + readClipboardText(): Promise { + return native("readClipboardText") as Promise; + }, + writeClipboardText(text: string): Promise { + return native("writeClipboardText", [text]) as Promise; + }, async setTheme(theme: Theme) { const themeStyle = document.querySelector("style#theme-data"); if (themeStyle) { @@ -123,3 +204,10 @@ export default { return native("setKeyboardSuggestionsEnabled", [enabled]) as Promise; }, }; + +function isDesktopCommand(value: unknown): value is DesktopCommand { + return ( + typeof value === "string" && + (DESKTOP_COMMAND_IDS as string[]).includes(value) + ); +} diff --git a/src/bridge/scanner.ts b/src/bridge/scanner.ts index 5ac2461..7ad7900 100644 --- a/src/bridge/scanner.ts +++ b/src/bridge/scanner.ts @@ -1,6 +1,7 @@ import type Theme from "classes/theme"; import permissions from "lib/permissions"; import bridge from "./bridge"; +import { bytesToBase64 } from "./file"; import native from "./native"; export const FORMAT_QR_CODE = 256; @@ -78,6 +79,11 @@ export default { observer.stop(); return res; }, + async scanImage(file: File | Blob): Promise { + const buffer = await file.arrayBuffer(); + const base64 = bytesToBase64(new Uint8Array(buffer)); + return (await scanner("scanImage", [base64])) as ScanResult[] | null; + }, setTheme(theme: Theme) { return scanner("setTheme", [theme.json]); }, diff --git a/src/browser/developerToolsRuntime.test.ts b/src/browser/developerToolsRuntime.test.ts new file mode 100644 index 0000000..74e23c8 --- /dev/null +++ b/src/browser/developerToolsRuntime.test.ts @@ -0,0 +1,347 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clampPanelPercent, + DEFAULT_PANEL_PERCENT, + type ErudaRuntime, + installShellularDeveloperTools, + MAX_PANEL_PERCENT, + MIN_PANEL_PERCENT, + type ShellularDeveloperToolsState, +} from "./developerToolsRuntime"; + +type FakeEruda = ErudaRuntime & { + initCount: number; + selectedElement: Element | null; + clearCount: number; + shownTool: string | undefined; + showCalls: Array; + panel: HTMLElement; +}; + +let testFrame: HTMLIFrameElement | null = null; + +function installTestDOM() { + const hostDocument = document; + testFrame = hostDocument.createElement("iframe"); + hostDocument.body.appendChild(testFrame); + const frameWindow = testFrame.contentWindow; + const frameDocument = testFrame.contentDocument; + if (!frameWindow || !frameDocument) { + throw new Error("Could not create an isolated browser test frame"); + } + const frameGlobals = frameWindow as Window & typeof globalThis; + frameDocument.open(); + frameDocument.write(""); + frameDocument.close(); + class TestResizeObserver { + observe() {} + disconnect() {} + unobserve() {} + } + if (!frameWindow.requestAnimationFrame) { + frameWindow.requestAnimationFrame = (callback: FrameRequestCallback) => + frameWindow.setTimeout(() => callback(Date.now()), 0); + } + vi.stubGlobal("window", frameWindow); + vi.stubGlobal("document", frameDocument); + vi.stubGlobal("Element", frameGlobals.Element); + vi.stubGlobal("HTMLElement", frameGlobals.HTMLElement); + vi.stubGlobal("MutationObserver", frameGlobals.MutationObserver); + vi.stubGlobal("ResizeObserver", TestResizeObserver); + return frameWindow; +} + +function makeFakeEruda(): FakeEruda { + const host = document.createElement("div"); + const shadowRoot = host.attachShadow({ mode: "open" }); + const panel = document.createElement("div"); + panel.className = "eruda-dev-tools"; + shadowRoot.appendChild(panel); + document.documentElement.appendChild(host); + + let displaySize = DEFAULT_PANEL_PERCENT; + const configListeners: Array<(name: string, value: unknown) => void> = []; + const fake: FakeEruda = { + initCount: 0, + selectedElement: null, + clearCount: 0, + shownTool: undefined, + showCalls: [], + panel, + _shadowRoot: shadowRoot, + _devTools: { + _isShow: false, + config: { + get(name: string) { + return name === "displaySize" ? displaySize : undefined; + }, + set(name: string, value: unknown) { + if (name !== "displaySize" || typeof value !== "number") return; + displaySize = value; + for (const listener of configListeners) listener(name, value); + }, + on(_event: "change", listener: (name: string, value: unknown) => void) { + configListeners.push(listener); + }, + }, + }, + init(options?: Record) { + fake.initCount += 1; + const defaults = options?.defaults as + | { displaySize?: number } + | undefined; + if (typeof defaults?.displaySize === "number") { + displaySize = defaults.displaySize; + } + }, + show(tool?: string) { + fake.showCalls.push(tool); + if (tool !== undefined) { + fake.shownTool = tool; + return; + } + if (fake._devTools) fake._devTools._isShow = true; + panel.style.display = "block"; + }, + hide() { + if (fake._devTools) fake._devTools._isShow = false; + window.setTimeout(() => { + panel.style.display = "none"; + }, 300); + }, + get(name: string) { + if (name === "entryBtn") return { hide: vi.fn() }; + if (name === "elements") { + return { + select(target: Element) { + fake.selectedElement = target; + }, + _detail: { + hide() { + fake.clearCount += 1; + }, + }, + }; + } + return undefined; + }, + }; + + Object.defineProperty(panel, "getBoundingClientRect", { + value: () => ({ + bottom: 800, + height: (800 * displaySize) / 100, + left: 0, + right: 600, + top: 800 - (800 * displaySize) / 100, + width: 600, + x: 0, + y: 800 - (800 * displaySize) / 100, + toJSON: () => ({}), + }), + }); + return fake; +} + +async function settleDOM() { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + await new Promise((resolve) => + window.requestAnimationFrame(() => resolve()), + ); + await new Promise((resolve) => + window.requestAnimationFrame(() => resolve()), + ); +} + +describe("Shellular developer tools runtime", () => { + beforeEach(() => { + installTestDOM(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + testFrame?.remove(); + testFrame = null; + }); + + it("clamps panel size to the supported range", () => { + expect(clampPanelPercent(Number.NaN)).toBe(DEFAULT_PANEL_PERCENT); + expect(clampPanelPercent(5)).toBe(MIN_PANEL_PERCENT); + expect(clampPanelPercent(50)).toBe(50); + expect(clampPanelPercent(95)).toBe(MAX_PANEL_PERCENT); + }); + + it("installs one versioned runtime and publishes validated state", () => { + const messages: ShellularDeveloperToolsState[] = []; + Object.defineProperty(window, "webkit", { + configurable: true, + value: { + messageHandlers: { + shellularDeveloperTools: { + postMessage: (value: ShellularDeveloperToolsState) => + messages.push(value), + }, + }, + }, + }); + const eruda = makeFakeEruda(); + const runtime = installShellularDeveloperTools(eruda, { + panelPercent: 54, + }); + const duplicate = installShellularDeveloperTools(eruda, { + panelPercent: 57, + }); + + expect(duplicate).toBe(runtime); + expect(eruda.initCount).toBe(1); + expect(runtime.getState()).toEqual({ + version: 1, + ready: true, + visible: false, + panelPercent: 57, + highlighted: false, + }); + expect(messages[messages.length - 1]).toEqual(runtime.getState()); + }); + + it("writes clamped Eruda resizes back before publishing panel geometry", async () => { + const messages: ShellularDeveloperToolsState[] = []; + Object.defineProperty(window, "webkit", { + configurable: true, + value: { + messageHandlers: { + shellularDeveloperTools: { + postMessage: (value: ShellularDeveloperToolsState) => + messages.push(value), + }, + }, + }, + }); + const eruda = makeFakeEruda(); + const runtime = installShellularDeveloperTools(eruda, { + panelPercent: 50, + }); + runtime.show(); + const messageCountBeforeResize = messages.length; + + eruda._devTools?.config?.set?.("displaySize", 95); + await settleDOM(); + + expect(eruda._devTools?.config?.get?.("displaySize")).toBe( + MAX_PANEL_PERCENT, + ); + expect(runtime.getState().panelPercent).toBe(MAX_PANEL_PERCENT); + expect(messages).toHaveLength(messageCountBeforeResize + 1); + expect(messages[messages.length - 1]).toEqual(runtime.getState()); + expect( + ( + document.getElementById( + "__shellularDeveloperToolsSpacer", + ) as HTMLElement + ).style.height, + ).toBe("560px"); + }); + + it("supports direct and legacy visibility controls", () => { + const eruda = makeFakeEruda(); + const runtime = installShellularDeveloperTools(eruda); + + expect(runtime.show("network")).toBe(true); + expect(runtime.getState().visible).toBe(true); + expect(eruda.shownTool).toBe("network"); + expect(eruda.showCalls).toEqual(["network", undefined]); + expect(eruda._devTools?._isShow).toBe(true); + document.dispatchEvent(new Event("hideconsole")); + expect(runtime.getState().visible).toBe(false); + document.dispatchEvent(new Event("showconsole")); + expect(runtime.getState().visible).toBe(true); + expect(runtime.toggle()).toBe(true); + expect(runtime.getState().visible).toBe(false); + }); + + it("selects and clears the element from the latest context menu", () => { + const eruda = makeFakeEruda(); + const runtime = installShellularDeveloperTools(eruda); + const button = document.createElement("button"); + button.id = "inspect-me"; + document.body.appendChild(button); + button.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + + expect(runtime.inspectElement()).toBe(true); + expect(eruda.shownTool).toBe("elements"); + expect(eruda.showCalls).toEqual(["elements", undefined]); + expect(eruda._devTools?._isShow).toBe(true); + expect(eruda.selectedElement).toBe(button); + expect(runtime.getState().highlighted).toBe(true); + expect(runtime.clearHighlight()).toBe(true); + expect(eruda.clearCount).toBeGreaterThan(0); + expect(runtime.getState().highlighted).toBe(false); + }); + + it("keeps the panel visible when a delayed Eruda hide races a named show", async () => { + const eruda = makeFakeEruda(); + const runtime = installShellularDeveloperTools(eruda); + + runtime.show(); + runtime.hide(); + runtime.show("elements"); + await new Promise((resolve) => window.setTimeout(resolve, 450)); + await settleDOM(); + + expect(runtime.getState().visible).toBe(true); + expect(eruda._devTools?._isShow).toBe(true); + expect(eruda.showCalls).toEqual([ + undefined, + "elements", + undefined, + undefined, + ]); + expect(eruda.panel.style.display).toBe("block"); + expect(eruda.shownTool).toBe("elements"); + }); + + it("keeps one correctly sized spacer after content and body replacement", async () => { + const eruda = makeFakeEruda(); + const runtime = installShellularDeveloperTools(eruda, { + panelPercent: 50, + }); + runtime.show(); + await settleDOM(); + + const lateContent = document.createElement("main"); + document.body.appendChild(lateContent); + await settleDOM(); + let spacer = document.getElementById( + "__shellularDeveloperToolsSpacer", + ) as HTMLElement; + expect(document.body.lastElementChild).toBe(spacer); + expect(spacer.style.height).toBe("400px"); + + const replacement = document.createElement("body"); + replacement.innerHTML = "
Replacement
"; + document.body.replaceWith(replacement); + await settleDOM(); + spacer = document.getElementById( + "__shellularDeveloperToolsSpacer", + ) as HTMLElement; + expect(document.body.lastElementChild).toBe(spacer); + expect( + document.querySelectorAll("#__shellularDeveloperToolsSpacer"), + ).toHaveLength(1); + }); + + it("installs memory storage when WKWebView storage access throws", () => { + Object.defineProperty(window, "localStorage", { + configurable: true, + get: () => { + throw new DOMException("Storage unavailable", "SecurityError"); + }, + }); + const eruda = makeFakeEruda(); + const runtime = installShellularDeveloperTools(eruda); + + expect(runtime.getState().ready).toBe(true); + expect(() => window.localStorage.setItem("key", "value")).not.toThrow(); + expect(window.localStorage.getItem("key")).toBe("value"); + }); +}); diff --git a/src/browser/developerToolsRuntime.ts b/src/browser/developerToolsRuntime.ts new file mode 100644 index 0000000..964d000 --- /dev/null +++ b/src/browser/developerToolsRuntime.ts @@ -0,0 +1,547 @@ +import { installDeveloperToolsStorageFallback } from "./developerToolsStorage"; + +export const DEVELOPER_TOOLS_VERSION = 1 as const; +export const DEFAULT_PANEL_PERCENT = 45; +export const MIN_PANEL_PERCENT = 25; +export const MAX_PANEL_PERCENT = 70; + +export type ShellularDeveloperTool = + | "console" + | "elements" + | "network" + | "resources" + | "sources" + | "info" + | "snippets"; + +export type ShellularDeveloperToolsState = { + version: typeof DEVELOPER_TOOLS_VERSION; + ready: boolean; + visible: boolean; + panelPercent: number; + highlighted: boolean; +}; + +export type ShellularDeveloperTools = { + readonly version: typeof DEVELOPER_TOOLS_VERSION; + show(tool?: ShellularDeveloperTool): boolean; + hide(): boolean; + toggle(): boolean; + inspectElement(target?: Element | null): boolean; + clearHighlight(): boolean; + setPanelPercent(value: number): number; + getState(): ShellularDeveloperToolsState; +}; + +type ErudaConfig = { + get?(name: string): unknown; + set?(name: string, value: unknown): void; + on?(event: "change", listener: (name: string, value: unknown) => void): void; +}; + +type ErudaElements = { + select?(target: Element): void; + hide?(): void; + _detail?: { hide?(): void }; +}; + +export type ErudaRuntime = { + init(options?: Record): void; + show(tool?: string): void; + hide(): void; + get?(name: string): unknown; + _shadowRoot?: ShadowRoot; + _devTools?: { + _isShow?: boolean; + config?: ErudaConfig; + show?(): void; + hide?(): void; + }; +}; + +type RuntimeWindow = Window & + typeof globalThis & { + eruda?: ErudaRuntime; + __shellularDeveloperTools?: ShellularDeveloperTools; + __shellularInstallDeveloperTools?: ( + options?: ShellularDeveloperToolsOptions, + ) => ShellularDeveloperTools; + __shellularSetDeveloperToolsVisible?: (visible: boolean) => boolean; + __shellularInspectContextElement?: () => boolean; + __shellularClearElementHighlight?: () => boolean; + webkit?: { + messageHandlers?: Record< + string, + { postMessage(value: ShellularDeveloperToolsState): void } + >; + }; + }; + +export type ShellularDeveloperToolsOptions = { + panelPercent?: number; + messageHandlerName?: string; +}; + +type RuntimeState = { + ready: boolean; + initializing: boolean; + requestedVisible: boolean; + appliedVisible: boolean; + panelPercent: number; + highlighted: boolean; + contextTarget: Element | null; + pendingInspectTarget: Element | null; + spacer: HTMLElement | null; + panel: HTMLElement | null; + resizeObserver: ResizeObserver | null; + panelObserver: MutationObserver | null; + documentObserver: MutationObserver | null; + bodyObserver: MutationObserver | null; + observedBody: HTMLElement | null; + visibilityReconciliationTimer: number | null; + waitingForDocument: boolean; + error: string | null; + lastMessage: string; +}; + +const SPACER_ID = "__shellularDeveloperToolsSpacer"; +const DEFAULT_MESSAGE_HANDLER = "shellularDeveloperTools"; +const VISIBILITY_RECONCILIATION_DELAY_MS = 350; + +export function clampPanelPercent(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_PANEL_PERCENT; + return Math.min(MAX_PANEL_PERCENT, Math.max(MIN_PANEL_PERCENT, value)); +} + +export function installShellularDeveloperTools( + eruda: ErudaRuntime, + options: ShellularDeveloperToolsOptions = {}, +): ShellularDeveloperTools { + const runtimeWindow = window as RuntimeWindow; + const existing = runtimeWindow.__shellularDeveloperTools; + if (existing?.version === DEVELOPER_TOOLS_VERSION) { + if (options.panelPercent !== undefined) { + existing.setPanelPercent(options.panelPercent); + } + return existing; + } + + const messageHandlerName = + options.messageHandlerName || DEFAULT_MESSAGE_HANDLER; + const state: RuntimeState = { + ready: false, + initializing: false, + requestedVisible: false, + appliedVisible: false, + panelPercent: clampPanelPercent( + options.panelPercent ?? DEFAULT_PANEL_PERCENT, + ), + highlighted: false, + contextTarget: null, + pendingInspectTarget: null, + spacer: null, + panel: null, + resizeObserver: null, + panelObserver: null, + documentObserver: null, + bodyObserver: null, + observedBody: null, + visibilityReconciliationTimer: null, + waitingForDocument: false, + error: null, + lastMessage: "", + }; + + function snapshot(): ShellularDeveloperToolsState { + return { + version: DEVELOPER_TOOLS_VERSION, + ready: state.ready, + visible: state.requestedVisible, + panelPercent: state.panelPercent, + highlighted: state.highlighted, + }; + } + + function publishState(): void { + const value = snapshot(); + const serialized = JSON.stringify(value); + if (serialized === state.lastMessage) return; + state.lastMessage = serialized; + try { + runtimeWindow.webkit?.messageHandlers?.[messageHandlerName]?.postMessage( + value, + ); + } catch { + // Native state synchronization is optional. Diagnostics must continue + // to work in ordinary browsers and on the existing mobile wrappers. + } + } + + function schedule(callback: () => void): void { + const enqueue = runtimeWindow.requestAnimationFrame + ? runtimeWindow.requestAnimationFrame.bind(runtimeWindow) + : (next: FrameRequestCallback) => + runtimeWindow.setTimeout(() => next(Date.now()), 0); + enqueue(() => enqueue(() => callback())); + } + + function elementsTool(): ErudaElements | null { + try { + return (eruda.get?.("elements") as ErudaElements | undefined) ?? null; + } catch { + return null; + } + } + + function clearHighlight(): boolean { + state.pendingInspectTarget = null; + const elements = elementsTool(); + let cleared = false; + try { + if (typeof elements?._detail?.hide === "function") { + elements._detail.hide(); + cleared = true; + } else if (typeof elements?.hide === "function") { + elements.hide(); + cleared = true; + } + } catch { + cleared = false; + } + state.highlighted = false; + publishState(); + return cleared; + } + + function placeSpacerAtDocumentEnd(): boolean { + const body = document.body; + if (!body || !state.spacer) return false; + if ( + state.spacer.parentNode !== body || + state.spacer !== body.lastElementChild + ) { + body.appendChild(state.spacer); + } + return true; + } + + function observeBody(): void { + const body = document.body; + if (!body || body === state.observedBody) return; + state.bodyObserver?.disconnect(); + state.observedBody = body; + state.bodyObserver = new MutationObserver(() => { + placeSpacerAtDocumentEnd(); + }); + state.bodyObserver.observe(body, { childList: true }); + } + + function panelFromEruda(): HTMLElement | null { + return ( + (eruda._shadowRoot?.querySelector( + ".eruda-dev-tools", + ) as HTMLElement | null) ?? + document.querySelector(".eruda-dev-tools") + ); + } + + function syncSpacer(): void { + observeBody(); + if (!placeSpacerAtDocumentEnd() || !state.spacer) return; + state.spacer.style.setProperty( + "display", + state.requestedVisible ? "block" : "none", + "important", + ); + const height = + state.requestedVisible && state.panel + ? Math.max(0, Math.ceil(state.panel.getBoundingClientRect().height)) + : 0; + state.spacer.style.setProperty("height", `${height}px`, "important"); + } + + function reconcileRequestedPanelVisibility(): void { + if ( + state.requestedVisible && + eruda._devTools?._isShow === true && + state.panel && + (state.panel.style.display === "none" || + runtimeWindow.getComputedStyle(state.panel).display === "none") + ) { + // Eruda hides with a delayed callback. If a hide is followed quickly by + // show, that stale callback can hide an otherwise-visible panel. Re-show + // when the DOM and Eruda's own visibility state disagree. + eruda.show(); + } + } + + function scheduleVisibilityReconciliation(): void { + if (state.visibilityReconciliationTimer !== null) { + runtimeWindow.clearTimeout(state.visibilityReconciliationTimer); + } + state.visibilityReconciliationTimer = runtimeWindow.setTimeout(() => { + state.visibilityReconciliationTimer = null; + reconcileRequestedPanelVisibility(); + syncSpacer(); + }, VISIBILITY_RECONCILIATION_DELAY_MS); + } + + function syncVisibilityFromEruda(): void { + const internalVisibility = eruda._devTools?._isShow; + if (typeof internalVisibility !== "boolean") return; + reconcileRequestedPanelVisibility(); + if (internalVisibility === state.requestedVisible) return; + state.requestedVisible = internalVisibility; + state.appliedVisible = internalVisibility; + if (!internalVisibility) clearHighlight(); + syncSpacer(); + publishState(); + } + + function installPanelObservers(): void { + const panel = panelFromEruda(); + if (!panel || panel === state.panel) return; + state.resizeObserver?.disconnect(); + state.panelObserver?.disconnect(); + state.panel = panel; + if (typeof ResizeObserver !== "undefined") { + state.resizeObserver = new ResizeObserver(() => { + syncSpacer(); + syncVisibilityFromEruda(); + }); + state.resizeObserver.observe(panel); + } + state.panelObserver = new MutationObserver(() => { + syncVisibilityFromEruda(); + syncSpacer(); + }); + state.panelObserver.observe(panel, { + attributes: true, + attributeFilter: ["class", "style"], + }); + } + + function installSpacer(): void { + if (!document.body) return; + if (!state.spacer) { + const spacer = document.createElement("div"); + spacer.id = SPACER_ID; + spacer.setAttribute("aria-hidden", "true"); + spacer.style.cssText = + "all:initial;display:none;width:100%;height:0;min-height:0;visibility:hidden;pointer-events:none;flex:none;"; + state.spacer = spacer; + } + installPanelObservers(); + syncSpacer(); + schedule(syncSpacer); + } + + function applyPanelPercent(): number { + state.panelPercent = clampPanelPercent(state.panelPercent); + try { + eruda._devTools?.config?.set?.("displaySize", state.panelPercent); + } catch { + // Keep the requested value even when an Eruda build lacks this API. + } + return state.panelPercent; + } + + function applyVisibility(tool?: ShellularDeveloperTool): boolean { + if (!state.ready) return true; + if (state.requestedVisible) { + // Eruda's named show overload only selects a tool. It does not reveal + // the hidden developer-tools container, so selection and visibility + // must be applied as two distinct operations. + if (tool) eruda.show(tool); + eruda.show(); + state.appliedVisible = true; + scheduleVisibilityReconciliation(); + } else { + if (state.visibilityReconciliationTimer !== null) { + runtimeWindow.clearTimeout(state.visibilityReconciliationTimer); + state.visibilityReconciliationTimer = null; + } + clearHighlight(); + if (state.appliedVisible || eruda._devTools?._isShow !== false) { + eruda.hide(); + } + state.appliedVisible = false; + } + installSpacer(); + publishState(); + return true; + } + + function inspectElement(target?: Element | null): boolean { + let candidate = target ?? state.contextTarget; + if ( + !candidate || + candidate.ownerDocument !== document || + candidate.isConnected === false + ) { + candidate = document.documentElement; + } + state.requestedVisible = true; + state.highlighted = true; + if (!state.ready) { + state.pendingInspectTarget = candidate; + initialize(); + publishState(); + return true; + } + applyVisibility("elements"); + try { + elementsTool()?.select?.(candidate); + } catch { + state.highlighted = false; + } + installSpacer(); + publishState(); + return true; + } + + function installDocumentObservers(): void { + if (state.documentObserver || !document.documentElement) return; + state.documentObserver = new MutationObserver(() => { + observeBody(); + installSpacer(); + }); + state.documentObserver.observe(document.documentElement, { + childList: true, + }); + } + + function initialize(): void { + if (state.ready || state.initializing) return; + if ( + document.readyState === "loading" || + !document.documentElement || + !document.head || + !document.body + ) { + if (!state.waitingForDocument) { + state.waitingForDocument = true; + const resume = () => { + if ( + document.readyState === "loading" || + !document.documentElement || + !document.head || + !document.body + ) { + return; + } + state.waitingForDocument = false; + state.documentObserver?.disconnect(); + state.documentObserver = null; + runtimeWindow.setTimeout(initialize, 0); + }; + document.addEventListener("DOMContentLoaded", resume, { once: true }); + if (document.readyState !== "loading") { + state.documentObserver = new MutationObserver(resume); + state.documentObserver.observe(document, { + childList: true, + subtree: true, + }); + } + } + return; + } + + state.initializing = true; + try { + installDeveloperToolsStorageFallback(runtimeWindow, "localStorage"); + installDeveloperToolsStorageFallback(runtimeWindow, "sessionStorage"); + eruda.init({ + autoScale: false, + defaults: { + displaySize: state.panelPercent, + theme: "System preference", + }, + }); + const entryButton = eruda.get?.("entryBtn") as + | { hide?(): void } + | undefined; + entryButton?.hide?.(); + let isCorrectingPanelPercent = false; + eruda._devTools?.config?.on?.("change", (name, value) => { + if (name !== "displaySize" || typeof value !== "number") return; + if (isCorrectingPanelPercent) return; + const next = clampPanelPercent(value); + const didStateChange = next !== state.panelPercent; + state.panelPercent = next; + if (next !== value) { + isCorrectingPanelPercent = true; + applyPanelPercent(); + isCorrectingPanelPercent = false; + } + syncSpacer(); + if (didStateChange) publishState(); + }); + state.ready = true; + state.initializing = false; + installDocumentObservers(); + installSpacer(); + applyPanelPercent(); + if (state.pendingInspectTarget) { + const pending = state.pendingInspectTarget; + state.pendingInspectTarget = null; + inspectElement(pending); + } else { + applyVisibility(); + } + publishState(); + } catch (error) { + state.initializing = false; + state.error = String(error instanceof Error ? error.message : error); + console.error("[Shellular Developer Tools]", error); + publishState(); + } + } + + const api: ShellularDeveloperTools = { + version: DEVELOPER_TOOLS_VERSION, + show(tool) { + state.requestedVisible = true; + initialize(); + return applyVisibility(tool); + }, + hide() { + state.requestedVisible = false; + initialize(); + return applyVisibility(); + }, + toggle() { + return state.requestedVisible ? api.hide() : api.show(); + }, + inspectElement, + clearHighlight, + setPanelPercent(value) { + state.panelPercent = clampPanelPercent(value); + if (state.ready) applyPanelPercent(); + syncSpacer(); + publishState(); + return state.panelPercent; + }, + getState: snapshot, + }; + + runtimeWindow.__shellularDeveloperTools = api; + runtimeWindow.__shellularSetDeveloperToolsVisible = (visible) => + visible ? api.show() : api.hide(); + runtimeWindow.__shellularInspectContextElement = () => api.inspectElement(); + runtimeWindow.__shellularClearElementHighlight = () => api.clearHighlight(); + + document.addEventListener( + "contextmenu", + (event) => { + state.contextTarget = + event.target instanceof Element ? event.target : null; + }, + true, + ); + document.addEventListener("showconsole", () => api.show()); + document.addEventListener("hideconsole", () => api.hide()); + + initialize(); + return api; +} diff --git a/src/browser/developerToolsStorage.ts b/src/browser/developerToolsStorage.ts new file mode 100644 index 0000000..d11412e --- /dev/null +++ b/src/browser/developerToolsStorage.ts @@ -0,0 +1,68 @@ +type StorageWindow = Window & typeof globalThis; + +const hasOwn = (value: object, key: PropertyKey) => + // biome-ignore lint/suspicious/noPrototypeBuiltins: Object.hasOwn is unavailable in legacy mobile WebViews. + Object.prototype.hasOwnProperty.call(value, key); + +function createMemoryStorage(): Storage { + let values: Record = Object.create(null) as Record< + string, + string + >; + let keys: string[] = []; + return { + get length() { + return keys.length; + }, + clear() { + values = Object.create(null) as Record; + keys = []; + }, + getItem(key) { + const normalized = String(key); + return hasOwn(values, normalized) ? values[normalized] : null; + }, + key(index) { + return keys[index] ?? null; + }, + removeItem(key) { + const normalized = String(key); + if (!hasOwn(values, normalized)) return; + delete values[normalized]; + keys = keys.filter((value) => value !== normalized); + }, + setItem(key, value) { + const normalized = String(key); + if (!hasOwn(values, normalized)) keys.push(normalized); + values[normalized] = String(value); + }, + }; +} + +export function installDeveloperToolsStorageFallback( + target: StorageWindow, + name: "localStorage" | "sessionStorage", +): void { + try { + const storage = target[name]; + if (storage && typeof storage.getItem === "function") return; + } catch { + // Access can throw for opaque WKWebView documents. + } + try { + Object.defineProperty(target, name, { + configurable: true, + enumerable: true, + value: createMemoryStorage(), + }); + } catch { + // Eruda may still be able to initialize without this storage implementation. + } +} + +export function installDeveloperToolsStorageFallbacks( + target: StorageWindow = window as StorageWindow, +): void { + installDeveloperToolsStorageFallback(target, "localStorage"); + installDeveloperToolsStorageFallback(target, "sessionStorage"); +} diff --git a/src/browser/developerToolsStorageBootstrap.ts b/src/browser/developerToolsStorageBootstrap.ts new file mode 100644 index 0000000..6ce1bcb --- /dev/null +++ b/src/browser/developerToolsStorageBootstrap.ts @@ -0,0 +1,3 @@ +import { installDeveloperToolsStorageFallbacks } from "./developerToolsStorage"; + +installDeveloperToolsStorageFallbacks(); diff --git a/src/browser/history.test.ts b/src/browser/history.test.ts new file mode 100644 index 0000000..321e30b --- /dev/null +++ b/src/browser/history.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { addBrowserHistory, getBrowserHistory, HISTORY_KEY } from "./history"; + +describe("browser history", () => { + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-21T00:00:00Z")); + }); + afterEach(() => vi.useRealTimers()); + + it("keeps fifty recent unique entries instead of truncating existing history early", () => { + const existing = Array.from({ length: 50 }, (_, index) => ({ + url: `https://example.com/${index}`, + title: `Page ${index}`, + favicon: "", + timestamp: index, + })); + localStorage.setItem(HISTORY_KEY, JSON.stringify(existing)); + + addBrowserHistory({ + url: "https://new.example.com", + title: "New page", + favicon: "", + }); + + const history = getBrowserHistory(); + expect(history).toHaveLength(50); + expect(history[0]).toMatchObject({ + url: "https://new.example.com", + title: "New page", + }); + expect(history[history.length - 1]?.url).toBe("https://example.com/48"); + }); + + it("moves a revisited address to the front without duplicating it", () => { + addBrowserHistory({ url: "https://one.test", title: "One", favicon: "" }); + addBrowserHistory({ url: "https://two.test", title: "Two", favicon: "" }); + addBrowserHistory({ + url: "https://one.test", + title: "One updated", + favicon: "icon", + }); + + expect(getBrowserHistory()).toEqual([ + expect.objectContaining({ + url: "https://one.test", + title: "One updated", + favicon: "icon", + }), + expect.objectContaining({ url: "https://two.test" }), + ]); + }); +}); diff --git a/src/browser/history.ts b/src/browser/history.ts index 3a2698e..3c2cf31 100644 --- a/src/browser/history.ts +++ b/src/browser/history.ts @@ -25,7 +25,7 @@ export function addBrowserHistory(entry: { }): void { const list = getBrowserHistory(); // Remove duplicate if same URL exists - const filtered = list.slice(0, 3).filter((e) => e.url !== entry.url); + const filtered = list.filter((e) => e.url !== entry.url); filtered.unshift({ url: entry.url, title: entry.title, diff --git a/src/browser/homeDocument.test.ts b/src/browser/homeDocument.test.ts new file mode 100644 index 0000000..6024485 --- /dev/null +++ b/src/browser/homeDocument.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HistoryEntry } from "./history"; + +const mocks = vi.hoisted(() => ({ + history: [] as HistoryEntry[], +})); + +vi.mock("./history", () => ({ + getBrowserHistory: () => mocks.history, +})); + +import { getBrowserHomeDocument } from "./homeDocument"; + +describe("browser home document", () => { + beforeEach(() => { + mocks.history = []; + }); + + it("produces a complete searchable home document without history", () => { + const document = getBrowserHomeDocument(); + + expect(document).toContain(""); + expect(document).toContain("Search or enter address"); + expect(document).toContain("No browsing history yet"); + }); + + it("includes current history in the generated document", () => { + mocks.history = [ + { + url: "https://example.test/path?value=1&next=2", + title: "Example ", + favicon: "", + timestamp: Date.now(), + }, + ]; + + const document = getBrowserHomeDocument(); + expect(document).toContain("Example <Project>"); + expect(document).toContain("https://example.test/path?value=1&next=2"); + }); +}); diff --git a/src/browser/homeDocument.ts b/src/browser/homeDocument.ts new file mode 100644 index 0000000..450e483 --- /dev/null +++ b/src/browser/homeDocument.ts @@ -0,0 +1,6 @@ +import { getBrowserHistory } from "./history"; +import { buildHomePage } from "./home"; + +export function getBrowserHomeDocument(): string { + return buildHomePage(getBrowserHistory()); +} diff --git a/src/browser/index.ts b/src/browser/index.ts index 0d9f2f7..e4234ae 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -1,6 +1,6 @@ import { fetchPorts, type PortEntry } from "state/ports"; -import { addBrowserHistory, getBrowserHistory } from "./history"; -import { buildHomePage } from "./home"; +import { addBrowserHistory } from "./history"; +import { getBrowserHomeDocument } from "./homeDocument"; import { buildPortsPage } from "./ports"; import { themeStyles } from "./shared"; @@ -37,7 +37,7 @@ export function initBrowserBridge(): void { window.__shellularPage = async (name: string): Promise => { switch (name) { case "home": - return buildHomePage(getBrowserHistory()); + return getBrowserHomeDocument(); case "ports": { try { const ports = await fetchPorts(); diff --git a/src/browser/proxy.ts b/src/browser/proxy.ts index b232ec7..1127ea2 100644 --- a/src/browser/proxy.ts +++ b/src/browser/proxy.ts @@ -8,11 +8,28 @@ import bridge from "bridge/bridge"; import { getConnectionSnapshot, onBinaryHttpResponseData, + onBinaryTcpTunnelData, onMessage, sendMessage, + sendTcpTunnelData, subscribeState, } from "state/connection"; +const TCP_TUNNEL_OPEN = "tcp:tunnel:open"; +const TCP_TUNNEL_OPENED = "tcp:tunnel:opened"; +const TCP_TUNNEL_WINDOW = "tcp:tunnel:window"; +const TCP_TUNNEL_END = "tcp:tunnel:end"; +const TCP_TUNNEL_CLOSE = "tcp:tunnel:close"; +const TCP_TUNNEL_CLOSED = "tcp:tunnel:closed"; +const TCP_TUNNEL_INITIAL_WINDOW_BYTES = 1024 * 1024; +const TCP_TUNNEL_MAX_FRAME_BYTES = 64 * 1024; +const TCP_TUNNEL_ALLOWED_HOSTS = new Set([ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", +]); + interface PendingRequest { nativeRequestId: string; requestUrl: string; @@ -27,8 +44,66 @@ interface PendingRequest { } const pending = new Map(); +interface PendingTunnel { + id: string; + sendSequence: number; + receiveSequence: number; + sendCredit: number; + receiveCredit: number; +} + +type TunnelControlMessage = { + id?: string; + respTo?: string; + type: string; + error?: string; + data: { + tunnelId: string; + windowBytes?: number; + bytes?: number; + }; +}; + +const tunnels = new Map(); let cleanupFns: (() => void)[] = []; +function parseTunnelControlMessage( + value: unknown, + expectedType: string, +): TunnelControlMessage | null { + if (!value || typeof value !== "object") return null; + const message = value as Partial; + if ( + message.type !== expectedType || + !message.data || + typeof message.data !== "object" || + typeof message.data.tunnelId !== "string" || + message.data.tunnelId.length < 8 || + message.data.tunnelId.length > 96 || + (message.error !== undefined && + (typeof message.error !== "string" || message.error.length > 512)) + ) { + return null; + } + if ( + message.data.windowBytes !== undefined && + (!Number.isInteger(message.data.windowBytes) || + message.data.windowBytes < 0 || + message.data.windowBytes > TCP_TUNNEL_INITIAL_WINDOW_BYTES) + ) { + return null; + } + if ( + message.data.bytes !== undefined && + (!Number.isInteger(message.data.bytes) || + message.data.bytes < 1 || + message.data.bytes > TCP_TUNNEL_INITIAL_WINDOW_BYTES) + ) { + return null; + } + return message as TunnelControlMessage; +} + declare global { interface Window { __shellularProxy: typeof __shellularProxy; @@ -51,6 +126,28 @@ function stringToBase64(str: string): string { return uint8ArrayToBase64(bytes); } +function base64ToUint8Array(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function sendTunnelMessage(message: { + type: string; + data: Record; +}): string | null { + return sendMessage(message as never); +} + +function nativeTunnelAction(action: string, args: unknown[]) { + return bridge("EmbeddedProxy")(action, args).catch((error) => { + console.error(`[TCP Tunnel] Native ${action} failed`, error); + }); +} + function logStaticAsset( url: string, status: number, @@ -162,6 +259,98 @@ const __shellularProxy = { }, }); }, + + tcpOpen( + tunnelId: string, + host: "localhost" | "127.0.0.1" | "::1" | "0.0.0.0", + port: number, + ): void { + if ( + tunnels.has(tunnelId) || + tunnelId.length < 8 || + tunnelId.length > 96 || + !TCP_TUNNEL_ALLOWED_HOSTS.has(host) || + !Number.isInteger(port) || + port < 1 || + port > 65_535 || + tunnels.size >= 64 + ) { + void nativeTunnelAction("tunnelClosed", [ + tunnelId, + "Invalid or excessive TCP tunnel request", + ]); + return; + } + const requestId = sendTunnelMessage({ + type: TCP_TUNNEL_OPEN, + data: { + tunnelId, + host, + port, + initialWindowBytes: TCP_TUNNEL_INITIAL_WINDOW_BYTES, + }, + }); + if (!requestId) { + void nativeTunnelAction("tunnelClosed", [ + tunnelId, + "The remote host is not connected", + ]); + return; + } + tunnels.set(tunnelId, { + id: tunnelId, + sendSequence: 0, + receiveSequence: 0, + sendCredit: 0, + receiveCredit: TCP_TUNNEL_INITIAL_WINDOW_BYTES, + }); + }, + + tcpData(tunnelId: string, chunkBase64: string): void { + const tunnel = tunnels.get(tunnelId); + if (!tunnel) return; + const data = base64ToUint8Array(chunkBase64); + if ( + data.byteLength === 0 || + data.byteLength > TCP_TUNNEL_MAX_FRAME_BYTES || + data.byteLength > tunnel.sendCredit + ) { + __shellularProxy.tcpClose(tunnelId); + return; + } + if ( + !sendTcpTunnelData(tunnelId, tunnel.sendSequence, data) + ) { + __shellularProxy.tcpClose(tunnelId); + return; + } + tunnel.sendSequence += 1; + tunnel.sendCredit -= data.byteLength; + }, + + tcpConsumed(tunnelId: string, bytes: number): void { + const tunnel = tunnels.get(tunnelId); + if (!tunnel || bytes <= 0 || bytes > TCP_TUNNEL_MAX_FRAME_BYTES) return; + tunnel.receiveCredit = Math.min( + TCP_TUNNEL_INITIAL_WINDOW_BYTES, + tunnel.receiveCredit + bytes, + ); + sendTunnelMessage({ + type: TCP_TUNNEL_WINDOW, + data: { tunnelId, bytes }, + }); + }, + + tcpEnd(tunnelId: string): void { + if (!tunnels.has(tunnelId)) return; + sendTunnelMessage({ type: TCP_TUNNEL_END, data: { tunnelId } }); + }, + + tcpClose(tunnelId: string): void { + if (!tunnels.delete(tunnelId)) return; + sendTunnelMessage({ type: TCP_TUNNEL_CLOSE, data: { tunnelId } }); + void nativeTunnelAction("tunnelClosed", [tunnelId, ""]); + }, }; function ensureHostHeader( @@ -270,6 +459,69 @@ function registerListeners() { } }), ); + + cleanupFns.push( + onMessage(TCP_TUNNEL_OPENED, (value) => { + const msg = parseTunnelControlMessage(value, TCP_TUNNEL_OPENED); + if (!msg) return; + const tunnel = tunnels.get(msg.data.tunnelId); + const credit = msg.data.windowBytes ?? 0; + if (!tunnel || credit <= 0) return; + tunnel.sendCredit = credit; + void nativeTunnelAction("tunnelOpened", [ + tunnel.id, + TCP_TUNNEL_INITIAL_WINDOW_BYTES, + credit, + ]); + }), + onMessage(TCP_TUNNEL_WINDOW, (value) => { + const msg = parseTunnelControlMessage(value, TCP_TUNNEL_WINDOW); + if (!msg) return; + const tunnel = tunnels.get(msg.data.tunnelId); + const bytes = msg.data.bytes ?? 0; + if (!tunnel || bytes <= 0) return; + tunnel.sendCredit = Math.min( + TCP_TUNNEL_INITIAL_WINDOW_BYTES, + tunnel.sendCredit + bytes, + ); + void nativeTunnelAction("tunnelWindow", [tunnel.id, bytes]); + }), + onMessage(TCP_TUNNEL_END, (value) => { + const msg = parseTunnelControlMessage(value, TCP_TUNNEL_END); + if (!msg) return; + if (!tunnels.has(msg.data.tunnelId)) return; + void nativeTunnelAction("tunnelEnd", [msg.data.tunnelId]); + }), + onMessage(TCP_TUNNEL_CLOSED, (value) => { + const msg = parseTunnelControlMessage(value, TCP_TUNNEL_CLOSED); + if (!msg) return; + if (!tunnels.delete(msg.data.tunnelId)) return; + void nativeTunnelAction("tunnelClosed", [ + msg.data.tunnelId, + msg.error ?? "", + ]); + }), + onBinaryTcpTunnelData((msg) => { + const tunnel = tunnels.get(msg.tunnelId); + if ( + !tunnel || + msg.sequence !== tunnel.receiveSequence || + msg.data.byteLength === 0 || + msg.data.byteLength > TCP_TUNNEL_MAX_FRAME_BYTES || + msg.data.byteLength > tunnel.receiveCredit + ) { + if (tunnel) __shellularProxy.tcpClose(msg.tunnelId); + return; + } + tunnel.receiveSequence += 1; + tunnel.receiveCredit -= msg.data.byteLength; + void nativeTunnelAction("tunnelData", [ + msg.tunnelId, + uint8ArrayToBase64(msg.data), + msg.sequence, + ]); + }), + ); } function unregisterListeners() { @@ -277,6 +529,13 @@ function unregisterListeners() { cleanupFns = []; } +function closeNativeTunnels(message: string) { + for (const tunnelId of tunnels.keys()) { + void nativeTunnelAction("tunnelClosed", [tunnelId, message]); + } + tunnels.clear(); +} + export function initProxyBridge(): void { window.__shellularProxy = __shellularProxy; @@ -287,6 +546,9 @@ export function initProxyBridge(): void { subscribeState(() => { const current = getConnectionSnapshot().connectionStatus; if (current !== lastStatus) { + if (current !== "connected") { + closeNativeTunnels("The remote host disconnected"); + } lastStatus = current; unregisterListeners(); registerListeners(); diff --git a/src/classes/theme.ts b/src/classes/theme.ts index fc059f2..d1eddce 100644 --- a/src/classes/theme.ts +++ b/src/classes/theme.ts @@ -2,6 +2,21 @@ import type { ITheme } from "@xterm/xterm"; import color from "classes/color"; import { isValidColor } from "./color/regex"; +export type ThemeSyntax = { + comment: string; + keyword: string; + string: string; + number: string; + function: string; + type: string; + variable: string; + property: string; + tag: string; + regexp: string; + operator: string; + constant: string; +}; + export default class Theme { static WHITE = "#ffffff"; static BLACK = "#000000"; @@ -258,6 +273,10 @@ export default class Theme { return this.secondaryText; } + get cardSubtextOpacity() { + return "0.6"; + } + get cardBg() { return this.secondary; } @@ -278,6 +297,45 @@ export default class Theme { return "rgba(0, 0, 0, 0.05)"; } + get scrollbarTrack() { + return "transparent"; + } + + get scrollbarThumb() { + return "rgba(255, 255, 255, 0.14)"; + } + + get scrollbarThumbHover() { + return "rgba(255, 255, 255, 0.24)"; + } + + get scrollbarThumbActive() { + return "rgba(255, 255, 255, 0.34)"; + } + + /** + * Shared semantic syntax colors for Monaco, CodeMirror, and diff views. + * Themes with a distinctive editor palette can override this without + * leaking editor-specific configuration into the rest of the app. + */ + get syntaxTheme(): ThemeSyntax { + const terminal = this.xtermTheme; + return { + comment: this.textMuted, + keyword: this.accent, + string: terminal.green ?? this.success, + number: terminal.blue ?? this.info, + function: terminal.brightBlue ?? terminal.blue ?? this.info, + type: terminal.brightYellow ?? terminal.yellow ?? this.accent, + variable: this.primaryText, + property: terminal.cyan ?? this.info, + tag: this.accent, + regexp: terminal.cyan ?? this.info, + operator: this.accent, + constant: terminal.magenta ?? this.accent2, + }; + } + get json() { const json: Record = {}; const prototype = Object.getPrototypeOf(Object.getPrototypeOf(this)); @@ -285,7 +343,7 @@ export default class Theme { const getters = props.filter(([, { get }]) => typeof get === "function"); for (const [key] of getters) { - if (["css", "json"].includes(key)) { + if (["css", "json", "syntaxTheme"].includes(key)) { continue; } @@ -349,6 +407,10 @@ export default class Theme { cursorAccent: this.primary, selectionBackground: "rgba(255, 255, 255, 0.18)", selectionInactiveBackground: "rgba(255, 255, 255, 0.12)", + scrollbarSliderBackground: this.scrollbarThumb, + scrollbarSliderHoverBackground: this.scrollbarThumbHover, + scrollbarSliderActiveBackground: this.scrollbarThumbActive, + overviewRulerBorder: this.lineSoft, }; } } diff --git a/src/components/AccountAvatarButton.tsx b/src/components/AccountAvatarButton.tsx new file mode 100644 index 0000000..7096d9c --- /dev/null +++ b/src/components/AccountAvatarButton.tsx @@ -0,0 +1,38 @@ +import clsx from "clsx"; +import { useAuth } from "lib/auth"; +import { getInitials } from "lib/utils"; + +interface Props { + className?: string; + onClick: () => void; +} + +export default function AccountAvatarButton({ className, onClick }: Props) { + const { user } = useAuth(); + if (!user) return null; + + return ( + + ); +} diff --git a/src/components/AppDialog.scss b/src/components/AppDialog.scss index b313401..285bee0 100644 --- a/src/components/AppDialog.scss +++ b/src/components/AppDialog.scss @@ -29,7 +29,8 @@ gap: 8px; padding-top: 6px; - input { + input, + select { width: 100%; min-height: 42px; border: 1px solid var(--card-border); @@ -47,6 +48,18 @@ } } } + + .app-dialog-form--stacked { + flex-direction: column; + + label { + display: grid; + gap: 6px; + color: var(--secondary-text); + font-size: 12px; + font-weight: 600; + } + } } .app-dialog { @@ -118,6 +131,11 @@ font-weight: 700; cursor: pointer; + &:disabled { + cursor: default; + opacity: 0.45; + } + &:active { background: var(--button-background-active); } @@ -149,4 +167,4 @@ opacity: 1; transform: translateY(0) scale(1); } -} \ No newline at end of file +} diff --git a/src/components/AppDialog.test.tsx b/src/components/AppDialog.test.tsx new file mode 100644 index 0000000..b12ff9d --- /dev/null +++ b/src/components/AppDialog.test.tsx @@ -0,0 +1,63 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import AppDialogHost, { openAppDialog } from "./AppDialog"; + +afterEach(cleanup); + +describe("app dialog", () => { + it("returns a selected project value", async () => { + render(); + let result: Promise | undefined; + act(() => { + result = openAppDialog("select", { + title: "Choose Project", + message: "Choose a project.", + options: [ + { value: "/alpha", label: "Alpha" }, + { value: "/beta", label: "Beta" }, + ], + }); + }); + fireEvent.change(screen.getByRole("combobox"), { + target: { value: "/beta" }, + }); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await expect(result).resolves.toBe("/beta"); + }); + + it("returns a selected project and filename together", async () => { + render(); + let result: + | Promise<{ selectedValue: string; textValue: string } | null> + | undefined; + act(() => { + result = openAppDialog("select-text", { + title: "New File", + message: "Choose a project and enter a filename.", + selectLabel: "Project", + textLabel: "Filename", + options: [ + { value: "/alpha", label: "Alpha" }, + { value: "/beta", label: "Beta" }, + ], + }); + }); + fireEvent.change(screen.getByRole("combobox", { name: "Project" }), { + target: { value: "/beta" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "Filename" }), { + target: { value: "index.ts" }, + }); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await expect(result).resolves.toEqual({ + selectedValue: "/beta", + textValue: "index.ts", + }); + }); +}); diff --git a/src/components/AppDialog.tsx b/src/components/AppDialog.tsx index b965b06..585e9a4 100644 --- a/src/components/AppDialog.tsx +++ b/src/components/AppDialog.tsx @@ -7,7 +7,24 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import "./AppDialog.scss"; -type DialogKind = "alert" | "confirm" | "prompt"; +type DialogKind = "alert" | "confirm" | "prompt" | "select" | "select-text"; + +export interface AppDialogOption { + value: string; + label: string; +} + +export interface AppDialogSelectTextValue { + selectedValue: string; + textValue: string; +} + +type DialogValue = + | boolean + | string + | AppDialogSelectTextValue + | null + | undefined; type DialogRequest = { id: number; @@ -15,13 +32,21 @@ type DialogRequest = { message: string; title?: string; defaultValue?: string; - resolve: (value: boolean | string | null | undefined) => void; + textDefaultValue?: string; + selectLabel?: string; + textLabel?: string; + options?: AppDialogOption[]; + resolve: (value: DialogValue) => void; }; type DialogOptions = { message: string; title?: string; defaultValue?: string; + textDefaultValue?: string; + selectLabel?: string; + textLabel?: string; + options?: AppDialogOption[]; }; let requestId = 0; @@ -40,6 +65,14 @@ export function openAppDialog( kind: "prompt", options: DialogOptions, ): Promise; +export function openAppDialog( + kind: "select", + options: DialogOptions, +): Promise; +export function openAppDialog( + kind: "select-text", + options: DialogOptions, +): Promise; export function openAppDialog(kind: DialogKind, options: DialogOptions) { return new Promise((resolve) => { if (!submitDialog) { @@ -49,6 +82,10 @@ export function openAppDialog(kind: DialogKind, options: DialogOptions) { message: options.message, title: options.title, defaultValue: options.defaultValue, + textDefaultValue: options.textDefaultValue, + selectLabel: options.selectLabel, + textLabel: options.textLabel, + options: options.options, resolve, }); return; @@ -60,6 +97,10 @@ export function openAppDialog(kind: DialogKind, options: DialogOptions) { message: options.message, title: options.title, defaultValue: options.defaultValue, + textDefaultValue: options.textDefaultValue, + selectLabel: options.selectLabel, + textLabel: options.textLabel, + options: options.options, resolve, }); }); @@ -79,13 +120,15 @@ export default function AppDialogHost() { }; }, []); - const close = (value: boolean | string | null | undefined) => { + const close = (value: DialogValue) => { if (!current) return; current.resolve(value); setQueue((prev) => prev.slice(1)); }; - return current ? : null; + return current ? ( + + ) : null; } function DialogSurface({ @@ -93,10 +136,12 @@ function DialogSurface({ close, }: { request: DialogRequest; - close: (value: boolean | string | null | undefined) => void; + close: (value: DialogValue) => void; }) { const [value, setValue] = useState(request.defaultValue ?? ""); + const [textValue, setTextValue] = useState(request.textDefaultValue ?? ""); const inputRef = useRef(null); + const selectRef = useRef(null); const cancelRef = useRef(null); const okRef = useRef(null); @@ -104,13 +149,24 @@ function DialogSurface({ if (request.title) return request.title; if (request.kind === "confirm") return "Confirm action"; if (request.kind === "prompt") return "Input required"; + if (request.kind === "select" || request.kind === "select-text") { + return "Choose an option"; + } return "Notice"; }, [request.kind, request.title]); useEffect(() => { - if (request.kind === "prompt") { - inputRef.current?.focus(); - inputRef.current?.select(); + if ( + request.kind === "prompt" || + request.kind === "select" || + request.kind === "select-text" + ) { + if (request.kind === "prompt") { + inputRef.current?.focus(); + inputRef.current?.select(); + } else { + selectRef.current?.focus(); + } return; } if (request.kind === "confirm") { @@ -128,7 +184,11 @@ function DialogSurface({ close(true); return; } - if (request.kind === "prompt") { + if (request.kind === "select-text") { + close({ selectedValue: value, textValue }); + return; + } + if (request.kind === "prompt" || request.kind === "select") { close(value); return; } @@ -181,6 +241,67 @@ function DialogSurface({ /> )} + {request.kind === "select" && ( +
{ + event.preventDefault(); + submit(); + }} + > + +
+ )} + {request.kind === "select-text" && ( +
{ + event.preventDefault(); + submit(); + }} + > + + +
+ )}
{request.kind !== "alert" && ( - )} + {({ focus }) => { + const className = `${focus ? "app-menu-item--active" : ""}${item.danger ? " app-menu-danger" : ""}${item.comingSoon ? " overlay-coming-soon" : ""} ${item.disabled ? "opacity-50 pointer-events-none" : ""}`; + const content = ( + <> +
))} @@ -108,3 +175,158 @@ export default function AppMenu({ ); } + +function DesktopContextualMenuButton({ + items, + children, + disabled, + ariaLabel, + onToggle, + buttonClassName = "", +}: Omit) { + const openingRef = useRef(false); + const open = async ( + origin: HTMLButtonElement, + anchor: ContextMenuAnchor, + trigger: ContextMenuTrigger, + ) => { + if (disabled || openingRef.current) return; + openingRef.current = true; + onToggle?.(true); + try { + await showAppMenuItems(items, anchor, origin, trigger); + } finally { + openingRef.current = false; + onToggle?.(false); + } + }; + return ( + + ); +} + +export function showAppMenuItems( + items: AppMenuItem[], + anchor: ContextMenuAnchor, + origin?: HTMLElement | null, + trigger: ContextMenuTrigger = "button", +) { + const target: CommandTarget = { handlers: {} }; + const resolved: ResolvedMenuItem[] = []; + let hasCommand = false; + for (const [index, item] of items.entries()) { + if ( + item.divider && + hasCommand && + resolved[resolved.length - 1]?.type !== "separator" + ) { + resolved.push({ type: "separator" }); + } + const command = `action.${item.key ?? slug(item.label)}.${index}`; + target.handlers[command] = { + run: item.onClick, + enabled: !item.disabled && !item.comingSoon, + }; + resolved.push({ + type: "command", + command, + label: item.label, + icon: item.icon, + macSymbol: macSymbolForIcon(item.icon), + danger: item.danger, + disabled: item.disabled || item.comingSoon, + checked: + item.radio || item.checked !== undefined + ? Boolean(item.checked) + : undefined, + radio: item.radio, + }); + hasCommand = true; + } + while (resolved[resolved.length - 1]?.type === "separator") resolved.pop(); + return showResolvedContextMenu( + { menuId: "action-menu", target, anchor, origin, trigger }, + resolved, + ); +} + +function slug(label: string) { + return label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +function macSymbolForIcon(icon: string) { + const symbols: Record = { + "icon-check": "checkmark", + "icon-copy": "doc.on.doc", + "icon-clipboard": "doc.on.clipboard", + "icon-download": "arrow.down.circle", + "icon-edit": "pencil", + "icon-edit-2": "pencil", + "icon-external-link": "arrow.up.forward.square", + "icon-file-plus": "doc.badge.plus", + "icon-folder-plus": "folder.badge.plus", + "icon-git-branch": "arrow.triangle.branch", + "icon-plus": "plus", + "icon-refresh-cw": "arrow.clockwise", + "icon-share": "square.and.arrow.up", + "icon-terminal": "terminal", + "icon-trash": "trash", + "icon-x": "xmark", + }; + return symbols[icon]; +} + +const RadioMenuItemButton = forwardRef< + HTMLButtonElement, + ButtonHTMLAttributes & { "aria-checked": boolean } +>(function RadioMenuItemButton( + { role: _headlessRole, "aria-checked": checked, ...props }, + ref, +) { + return ( + + + + + )} + {cli ? ( + <> + {cli.qrData && ( +
+

+ Remote pairing +

+
+ {qrCode && ( + Remote pairing QR code + )} +
+ {cli.qrData} +
+
+
+ )} +
+ setClientsOpen((value) => !value)} + title="Clients" + summary={`${connectedClients} connected · ${approvedClients} allowed`} + /> + {clientsOpen && + (cli.clients.length === 0 ? ( +

+ No clients yet. +

+ ) : ( +
+ {cli.clients.map((client) => ( +
+
+
+ {client.deviceModel ?? client.platform} +
+
+ {client.clientId} + {client.connected ? " · connected" : ""} +
+
+ +
+ ))} +
+ ))} +
+
+ setAdvancedOpen((value) => !value)} + title="Advanced" + summary={`CLI v${cli.cliVersion ?? "unknown"}`} + /> + {advancedOpen && ( +
+
+ + + + +
+
+ + +
+
+ )} +
+ {logsOpen && ( + setLogsOpen(false)} /> + )} + + ) : ( + !busy && + !error && ( + + ) + )} + + ); +} + +function DisclosureButton({ + open, + onClick, + title, + summary, +}: { + open: boolean; + onClick: () => void; + title: string; + summary: string; +}) { + return ( + + ); +} + +function LogsDialog({ + logs, + onClose, +}: { + logs: Array<{ id: number; level: string; message: string }>; + onClose: () => void; +}) { + return ( +
+
+
+
+
+ +
+
+ {logs.slice(-300).map((entry) => ( +
+ {entry.message} +
+ ))} +
+
+
+ ); +} + +function Info({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} diff --git a/src/components/Page.scss b/src/components/Page.scss index 7cd20d8..8c7bf1e 100644 --- a/src/components/Page.scss +++ b/src/components/Page.scss @@ -55,11 +55,95 @@ position: relative; display: flex; flex-direction: column; - height: 100%; + flex: 1 1 auto; + min-height: 0; + height: auto; overflow: auto; padding: 16px 16px 15vh 16px; width: 100%; max-width: 1200px; margin: 0 auto; } -} \ No newline at end of file + + >.page-toolbar { + flex: 0 0 auto; + width: 100%; + padding: 8px 12px; + border-bottom: 1px solid var(--line-soft); + background: color-mix(in srgb, var(--secondary) 58%, var(--primary)); + } +} + +.page-search-toolbar { + transform-origin: top center; + animation: page-search-toolbar-in 160ms cubic-bezier(0.2, 0, 0, 1) both; + + &[data-state="closing"] { + pointer-events: none; + animation: page-search-toolbar-out 140ms cubic-bezier(0.4, 0, 1, 1) both; + } +} + +.page-secondary-panel-overlay { + .page-secondary-panel-backdrop { + opacity: 1; + animation: page-panel-backdrop-in 180ms ease both; + } + + .page-secondary-panel { + animation: page-panel-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1) both; + } + + &[data-state="closing"] { + .page-secondary-panel-backdrop { + animation: page-panel-backdrop-out 160ms ease both; + } + + .page-secondary-panel { + animation: page-panel-out 160ms cubic-bezier(0.4, 0, 1, 1) both; + } + } +} + +.page-secondary-panel-mobile { + padding-top: var(--sat, 0px); + padding-bottom: var(--sab, 0px); +} + +@keyframes page-search-toolbar-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes page-search-toolbar-out { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-4px); } +} + +@keyframes page-panel-in { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} + +@keyframes page-panel-out { + from { transform: translateX(0); } + to { transform: translateX(-100%); } +} + +@keyframes page-panel-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes page-panel-backdrop-out { + from { opacity: 1; } + to { opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .page-search-toolbar, + .page-secondary-panel-overlay .page-secondary-panel-backdrop, + .page-secondary-panel-overlay .page-secondary-panel { + animation-duration: 1ms !important; + } +} diff --git a/src/components/Page.tsx b/src/components/Page.tsx index 73a00b4..cfe8798 100644 --- a/src/components/Page.tsx +++ b/src/components/Page.tsx @@ -1,19 +1,31 @@ import "./Page.scss"; import type { CSSProperties, ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { + useWorkbenchPageChromeTargets, + type WorkbenchChromeButton, +} from "../workbench/pageChrome"; import PageHeader from "./PageHeader"; +import PageSecondaryPanelFrame, { + type PageSecondaryPanel, +} from "./PageSecondaryPanel"; interface Props { title: string; subtitle?: string; rightSlot?: ReactNode; titleSlot?: ReactNode; + toolbarSlot?: ReactNode; + secondaryPanel?: PageSecondaryPanel; children: ReactNode; + footerSlot?: ReactNode; className?: string; style?: CSSProperties; zIndex?: number; noBottomSafeArea?: boolean; reverseTruncate?: boolean; scrollRef?: React.RefObject; + desktopNavigationControls?: WorkbenchChromeButton[]; } export default function Page({ @@ -21,29 +33,74 @@ export default function Page({ subtitle, rightSlot, titleSlot, + toolbarSlot, + secondaryPanel, children, + footerSlot, className, style, zIndex, noBottomSafeArea, reverseTruncate = false, scrollRef, + desktopNavigationControls, }: Props) { + const workbenchChrome = useWorkbenchPageChromeTargets(); + const renderInWorkbenchChrome = workbenchChrome?.embedded; + return (
- + {renderInWorkbenchChrome ? ( + <> + {workbenchChrome.visible && + desktopNavigationControls?.length && + workbenchChrome.targets.navigation && + createPortal( +
+ {desktopNavigationControls.map((control) => ( + + ))} +
, + workbenchChrome.targets.navigation, + )} + {workbenchChrome.visible && + rightSlot && + workbenchChrome.targets.actions && + createPortal( +
{rightSlot}
, + workbenchChrome.targets.actions, + )} + + ) : ( + + )} + {toolbarSlot ?
{toolbarSlot}
: null}
{children}
+ {footerSlot} + {secondaryPanel && !renderInWorkbenchChrome && ( + + )}
); } diff --git a/src/components/PageSearchToolbar.test.tsx b/src/components/PageSearchToolbar.test.tsx new file mode 100644 index 0000000..7006fbc --- /dev/null +++ b/src/components/PageSearchToolbar.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createRef } from "react"; +import { describe, expect, it, vi } from "vitest"; +import PageSearchToolbar from "./PageSearchToolbar"; + +describe("PageSearchToolbar", () => { + it("supports query changes, clearing, and Escape dismissal", () => { + const onChange = vi.fn(); + const onDismiss = vi.fn(); + const inputRef = createRef(); + render( + , + ); + + const input = screen.getByRole("searchbox", { name: "Search sessions" }); + expect(inputRef.current).toBe(input); + fireEvent.change(input, { target: { value: "next" } }); + expect(onChange).toHaveBeenCalledWith("next"); + + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + expect(onChange).toHaveBeenCalledWith(""); + + fireEvent.keyDown(input, { key: "Escape" }); + expect(onDismiss).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: "Close search" })).toBeNull(); + }); +}); diff --git a/src/components/PageSearchToolbar.tsx b/src/components/PageSearchToolbar.tsx new file mode 100644 index 0000000..f3780fd --- /dev/null +++ b/src/components/PageSearchToolbar.tsx @@ -0,0 +1,56 @@ +import type { RefObject } from "react"; + +interface PageSearchToolbarProps { + value: string; + onChange: (value: string) => void; + onDismiss: () => void; + placeholder: string; + ariaLabel: string; + inputRef?: RefObject; + closing?: boolean; +} + +export default function PageSearchToolbar({ + value, + onChange, + onDismiss, + placeholder, + ariaLabel, + inputRef, + closing = false, +}: PageSearchToolbarProps) { + return ( + +
+
+
+ ); +} diff --git a/src/components/PageSecondaryPanel.tsx b/src/components/PageSecondaryPanel.tsx new file mode 100644 index 0000000..1aa75ee --- /dev/null +++ b/src/components/PageSecondaryPanel.tsx @@ -0,0 +1,106 @@ +import actionStack from "lib/actionStack"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import type { PageSecondaryPanelController } from "workbench/pageChrome"; + +export interface PageSecondaryPanel { + key: string; + ariaLabel: string; + title: ReactNode; + body: ReactNode; + footer?: ReactNode; + controller: PageSecondaryPanelController; +} + +export default function PageSecondaryPanelFrame({ + panel, +}: { + panel: PageSecondaryPanel; +}) { + const { controller } = panel; + const [mounted, setMounted] = useState(controller.isOpen); + const closeRef = useRef(null); + + useEffect(() => { + if (controller.isOpen) { + setMounted(true); + requestAnimationFrame(() => closeRef.current?.focus()); + return; + } + const timer = window.setTimeout(() => setMounted(false), 180); + return () => window.clearTimeout(timer); + }, [controller.isOpen]); + + useEffect(() => { + if (!controller.isOpen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + controller.close(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [controller]); + + useEffect(() => { + if (!controller.isOpen) return; + actionStack.push({ + id: controller.panelId, + action: () => { + controller.close(); + }, + }); + return () => { + actionStack.remove(controller.panelId); + }; + }, [controller]); + + if (!mounted) return null; + + const width = 300; + const panelContent = ( + + ); + + return ( +
+
+ ); +} diff --git a/src/components/Scanner.tsx b/src/components/Scanner.tsx index 509e6c8..f020098 100644 --- a/src/components/Scanner.tsx +++ b/src/components/Scanner.tsx @@ -3,7 +3,6 @@ import browser from "bridge/browser"; import scanner from "bridge/scanner"; import ConnectingIcon from "components/ConnectingIcon"; import Mascot from "components/Mascot"; -import jsQR from "jsqr"; import actionStack from "lib/actionStack"; import { useCallback, useEffect, useRef, useState } from "react"; import { useShellular } from "state"; @@ -29,9 +28,9 @@ export default function Scanner({ const connecting = connectionStatus === "connecting"; - const connectWithToken = useCallback( - async (rawValue: string | undefined | null) => { - if (!rawValue) { + const connectToHost = useCallback( + async (hostAndKey: string) => { + if (!hostAndKey) { setError( "This QR code did not contain a valid connection token. Please try again.", ); @@ -39,7 +38,7 @@ export default function Scanner({ } try { - await connect(rawValue); + await connect(hostAndKey); return true; } catch (err) { if (err instanceof Error) { @@ -84,8 +83,14 @@ export default function Scanner({ return; } - const token = results.find((token) => Boolean(token.rawValue)); - const success = await connectWithToken(token?.rawValue); + const result = results.find((s) => Boolean(s.rawValue)); + if (!result?.rawValue) { + throw new Error( + "Either there was no QR code, or the value was invalid. Please try again with a valid QR code.", + ); + } + + const success = await connectToHost(result.rawValue); if (success) { setShowScanner(false); @@ -102,7 +107,7 @@ export default function Scanner({ } actionStack.remove("qr-code-scanner"); - }, [connectWithToken, setShowScanner, handleCancelScanner]); + }, [connectToHost, setShowScanner, handleCancelScanner]); const handleUploadQr = useCallback( async (file: File | undefined) => { @@ -114,8 +119,14 @@ export default function Scanner({ setUploadingQr(true); scanner.hide().catch(console.error); - const rawValue = await decodeQrImage(file); - const connected = await connectWithToken(rawValue); + const results = await scanner.scanImage(file); + const result = results?.find((s) => Boolean(s.rawValue)); + if (!result?.rawValue) { + throw new Error( + "Either there was no QR code, or the value was invalid. Please try again with a valid QR code.", + ); + } + const connected = await connectToHost(result.rawValue); if (connected) { setShowScanner(false); } @@ -133,7 +144,7 @@ export default function Scanner({ } } }, - [connectWithToken, setShowScanner, uploadingQr], + [connectToHost, setShowScanner, uploadingQr], ); useEffect(() => { @@ -285,43 +296,3 @@ export default function Scanner({ ); } - -async function decodeQrImage(file: File) { - const image = await loadImage(file); - try { - const canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - - const context = canvas.getContext("2d", { willReadFrequently: true }); - if (!context) { - throw new Error("Could not read this image. Please try another file."); - } - - context.drawImage(image, 0, 0); - - const imageData = context.getImageData(0, 0, canvas.width, canvas.height); - const qrCode = jsQR(imageData.data, imageData.width, imageData.height); - if (!qrCode?.data) { - throw new Error( - "No QR code was found in this image. Please try another image.", - ); - } - - return qrCode.data; - } finally { - URL.revokeObjectURL(image.src); - } -} - -function loadImage(file: File) { - return new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => { - URL.revokeObjectURL(image.src); - reject(new Error("Could not load this image. Please try another file.")); - }; - image.src = URL.createObjectURL(file); - }); -} diff --git a/src/components/SemanticStatusIcon.test.tsx b/src/components/SemanticStatusIcon.test.tsx new file mode 100644 index 0000000..abd563b --- /dev/null +++ b/src/components/SemanticStatusIcon.test.tsx @@ -0,0 +1,40 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import SemanticStatusIcon from "./SemanticStatusIcon"; + +afterEach(cleanup); + +describe("SemanticStatusIcon", () => { + it("uses consistent dimensions, semantic color, and an accessible label", () => { + render( + , + ); + + const status = screen.getByRole("img", { name: "Finished" }); + expect(status).toHaveClass("size-4", "text-success"); + expect(status).toHaveAttribute("title", "Finished"); + expect(status.firstElementChild).toHaveClass( + "icon-check-circle", + "text-[14px]", + ); + }); + + it("uses the shared motion-safe animation treatment", () => { + render( + , + ); + + expect( + screen.getByRole("img", { name: "Working" }).firstElementChild, + ).toHaveClass("animate-spin", "motion-reduce:animate-none"); + }); +}); diff --git a/src/components/SemanticStatusIcon.tsx b/src/components/SemanticStatusIcon.tsx new file mode 100644 index 0000000..c03ff4d --- /dev/null +++ b/src/components/SemanticStatusIcon.tsx @@ -0,0 +1,52 @@ +import clsx from "clsx"; + +export type SemanticStatusTone = + | "success" + | "warning" + | "danger" + | "info" + | "muted"; + +const TONE_CLASS: Record = { + success: "text-success", + warning: "text-warning", + danger: "text-danger", + info: "text-info", + muted: "text-secondary-text opacity-70", +}; + +export default function SemanticStatusIcon({ + icon, + label, + tone, + animated = false, + className, +}: { + icon: string; + label: string; + tone: SemanticStatusTone; + animated?: boolean; + className?: string; +}) { + return ( + + + ); +} diff --git a/src/console.ts b/src/console.ts index efe139e..155b001 100644 --- a/src/console.ts +++ b/src/console.ts @@ -1,5 +1,23 @@ +import "./browser/developerToolsStorageBootstrap"; import eruda from "eruda"; +import { + installShellularDeveloperTools, + type ShellularDeveloperTools, + type ShellularDeveloperToolsOptions, +} from "./browser/developerToolsRuntime"; -(window as unknown as { eruda?: typeof eruda }).eruda = eruda; +type ConsoleWindow = Window & + typeof globalThis & { + eruda?: typeof eruda; + __shellularDeveloperTools?: ShellularDeveloperTools; + __shellularInstallDeveloperTools?: ( + options?: ShellularDeveloperToolsOptions, + ) => ShellularDeveloperTools; + }; + +const consoleWindow = window as ConsoleWindow; +consoleWindow.eruda = eruda; +consoleWindow.__shellularInstallDeveloperTools = (options) => + installShellularDeveloperTools(eruda, options); export default eruda; diff --git a/src/context-menu/ContextMenuButton.tsx b/src/context-menu/ContextMenuButton.tsx new file mode 100644 index 0000000..1fac932 --- /dev/null +++ b/src/context-menu/ContextMenuButton.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; +import { showContextMenu } from "./service"; +import type { CommandId, CommandTarget, ContextMenuId } from "./types"; + +export default function ContextMenuButton({ + ariaLabel, + className, + children, + menuId, + commandGroups, + target, + disabled, +}: { + ariaLabel: string; + className?: string; + children: ReactNode; + menuId: ContextMenuId; + commandGroups?: readonly (readonly CommandId[])[]; + target: CommandTarget; + disabled?: boolean; +}) { + const open = ( + origin: HTMLButtonElement, + anchor: Parameters[0]["anchor"], + trigger: Parameters[0]["trigger"], + ) => + showContextMenu({ menuId, commandGroups, target, origin, anchor, trigger }); + return ( + + ); +} diff --git a/src/context-menu/ContextMenuHost.tsx b/src/context-menu/ContextMenuHost.tsx new file mode 100644 index 0000000..cd61022 --- /dev/null +++ b/src/context-menu/ContextMenuHost.tsx @@ -0,0 +1,582 @@ +import { + type KeyboardEvent, + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { createPortal } from "react-dom"; +import { + canRunDomEditCommand, + runDomEditCommand, + type WorkbenchEditCommand, +} from "workbench/domEditCommands"; +import { currentVisualViewport, placeContextMenu } from "./position"; +import { + dismissContextMenu, + getContextMenuSnapshot, + selectContextMenuCommand, + showContextMenu, + subscribeContextMenu, +} from "./service"; +import type { ResolvedMenuItem } from "./types"; + +export default function ContextMenuHost() { + const snapshot = useSyncExternalStore( + subscribeContextMenu, + getContextMenuSnapshot, + getContextMenuSnapshot, + ); + const menuRef = useRef(null); + const [position, setPosition] = useState({ + left: -10000, + top: -10000, + maxHeight: 0, + }); + const [focused, setFocused] = useState(0); + const [openSubmenu, setOpenSubmenu] = useState(null); + const typeahead = useRef(""); + const typeaheadTimer = useRef | null>(null); + + useEffect(() => { + if (!process.env.IS_DESKTOP_UI) return; + const suppress = (event: MouseEvent) => event.preventDefault(); + const provideFallback = (event: MouseEvent) => { + const pathTarget = event + .composedPath() + .find((entry) => entry instanceof HTMLElement); + const target = pathTarget instanceof HTMLElement ? pathTarget : null; + const editable = editableElement(target); + const selection = window.getSelection(); + const hasSelection = Boolean( + selection && !selection.isCollapsed && selection.toString(), + ); + if (!editable && !hasSelection) return; + const origin = editable ?? target; + const bounds = origin?.getBoundingClientRect(); + const anchor = + event.clientX || event.clientY || !bounds + ? { kind: "point" as const, x: event.clientX, y: event.clientY } + : { + kind: "rect" as const, + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + }; + void showContextMenu({ + menuId: editable ? "text-edit" : "text-selection", + anchor, + trigger: + event.button !== 2 && + !event.ctrlKey && + !event.clientX && + !event.clientY + ? "keyboard" + : "context", + origin, + target: createDomCommandTarget(editable ?? target), + }); + }; + document.addEventListener("contextmenu", suppress, { capture: true }); + document.addEventListener("contextmenu", provideFallback); + return () => { + document.removeEventListener("contextmenu", suppress, { capture: true }); + document.removeEventListener("contextmenu", provideFallback); + }; + }, []); + + useLayoutEffect(() => { + if (!snapshot || !menuRef.current) return; + const menu = menuRef.current; + const update = () => { + setPosition( + placeContextMenu( + snapshot.invocation.anchor, + menu.getBoundingClientRect(), + currentVisualViewport(), + ), + ); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(menu); + window.addEventListener("resize", update); + window.visualViewport?.addEventListener("resize", update); + window.visualViewport?.addEventListener("scroll", update); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + window.visualViewport?.removeEventListener("resize", update); + window.visualViewport?.removeEventListener("scroll", update); + }; + }, [snapshot]); + + useEffect(() => { + if (!snapshot) return; + setFocused(firstEnabled(snapshot.items)); + setOpenSubmenu(null); + requestAnimationFrame(() => + menuRef.current?.focus({ preventScroll: true }), + ); + }, [snapshot]); + + useEffect( + () => () => { + if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current); + }, + [], + ); + + if (!snapshot || process.env.IS_MACOS) return null; + const selectableItems = menuEntries(snapshot.items); + const move = (direction: 1 | -1) => { + if (selectableItems.length === 0) return; + let next = focused; + for (let count = 0; count < selectableItems.length; count += 1) { + next = + (next + direction + selectableItems.length) % selectableItems.length; + if (!isDisabled(selectableItems[next])) break; + } + setOpenSubmenu(null); + setFocused(next); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + dismissContextMenu(); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + move(1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + move(-1); + } else if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + setFocused( + event.key === "Home" + ? firstEnabled(snapshot.items) + : lastEnabled(snapshot.items), + ); + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + const item = selectableItems[focused]; + if (item?.type === "submenu") setOpenSubmenu(focused); + else if (item && !item.disabled) + void selectContextMenuCommand(snapshot.id, item.command); + } else if (event.key === "ArrowRight") { + const item = selectableItems[focused]; + if (item?.type === "submenu") { + event.preventDefault(); + setOpenSubmenu(focused); + } + } else if (event.key.length === 1 && /\S/.test(event.key)) { + typeahead.current += event.key.toLowerCase(); + if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current); + typeaheadTimer.current = setTimeout(() => { + typeahead.current = ""; + }, 500); + const index = selectableItems.findIndex( + (item) => + !isDisabled(item) && + item.label.toLowerCase().startsWith(typeahead.current), + ); + if (index >= 0) setFocused(index); + } + }; + + let itemIndex = -1; + return createPortal( + <> + + ); + })} + + , + document.body, + ); +} + +type SubmenuItem = Extract; + +function BrowserSubmenu({ + item, + invocationId, + active, + open, + onFocus, + onClose, +}: { + item: SubmenuItem; + invocationId: number; + active: boolean; + open: boolean; + onFocus: () => void; + onClose: () => void; +}) { + const triggerRef = useRef(null); + const panelRef = useRef(null); + const [position, setPosition] = useState({ + left: -10000, + top: -10000, + maxHeight: 0, + }); + const entries = menuEntries(item.items); + const [focused, setFocused] = useState(firstEnabled(item.items)); + const [openChild, setOpenChild] = useState(null); + useLayoutEffect(() => { + if (!open || !triggerRef.current || !panelRef.current) return; + const update = () => { + const trigger = triggerRef.current?.getBoundingClientRect(); + const panel = panelRef.current?.getBoundingClientRect(); + if (!trigger || !panel) return; + setPosition(placeSubmenu(trigger, panel, currentVisualViewport())); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(panelRef.current); + window.addEventListener("resize", update); + window.visualViewport?.addEventListener("resize", update); + window.visualViewport?.addEventListener("scroll", update); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + window.visualViewport?.removeEventListener("resize", update); + window.visualViewport?.removeEventListener("scroll", update); + }; + }, [open]); + useEffect(() => { + if (!open) return; + setFocused(firstEnabled(item.items)); + setOpenChild(null); + requestAnimationFrame(() => + panelRef.current?.focus({ preventScroll: true }), + ); + }, [item.items, open]); + const move = (direction: 1 | -1) => { + if (!entries.length) return; + let next = focused; + for (let count = 0; count < entries.length; count += 1) { + next = (next + direction + entries.length) % entries.length; + if (!isDisabled(entries[next])) break; + } + setOpenChild(null); + setFocused(next); + }; + return ( + <> + + {open && + createPortal( +
{ + event.stopPropagation(); + if (event.key === "ArrowDown") { + event.preventDefault(); + move(1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + move(-1); + } else if (event.key === "ArrowLeft" || event.key === "Escape") { + event.preventDefault(); + onClose(); + } else if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + setFocused( + event.key === "Home" + ? firstEnabled(item.items) + : lastEnabled(item.items), + ); + } else if (event.key === "ArrowRight") { + const selected = entries[focused]; + if (selected?.type === "submenu") { + event.preventDefault(); + setOpenChild(focused); + } + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + const selected = entries[focused]; + if (selected?.type === "command" && !selected.disabled) { + void selectContextMenuCommand(invocationId, selected.command); + } else if (selected?.type === "submenu") { + setOpenChild(focused); + } + } + }} + > + {item.items.map((child, index) => { + if (child.type === "separator") { + return ( +
+ ); + } + const childIndex = entries.indexOf(child); + if (child.type === "submenu") { + return ( + { + setFocused(childIndex); + setOpenChild(childIndex); + }} + onClose={() => { + setOpenChild(null); + panelRef.current?.focus({ preventScroll: true }); + }} + /> + ); + } + return ( + + ); + })} +
, + document.body, + )} + + ); +} + +const DOM_COMMANDS: Record = { + "edit.undo": "undo", + "edit.redo": "redo", + "edit.cut": "cut", + "edit.copy": "copy", + "edit.paste": "paste", + "edit.selectAll": "select-all", +}; + +function createDomCommandTarget(target: HTMLElement | null) { + return { + handlers: Object.fromEntries( + Object.entries(DOM_COMMANDS).map(([id, command]) => [ + id, + { + run: () => runDomEditCommand(command, target), + enabled: () => canRunDomEditCommand(command, target), + }, + ]), + ), + }; +} + +function editableElement(target: HTMLElement | null) { + if (!target) return null; + if (target instanceof HTMLTextAreaElement) return target; + if ( + target instanceof HTMLInputElement && + /^(?:text|search|url|tel|email|password|number)$/i.test(target.type) + ) + return target; + return target.isContentEditable + ? target + : target.closest("[contenteditable=true]"); +} + +function menuEntries(items: ResolvedMenuItem[]) { + return items.filter((item) => item.type !== "separator"); +} + +function isDisabled( + item: Exclude | undefined, +) { + return item?.type === "command" ? Boolean(item.disabled) : false; +} + +function firstEnabled(items: ResolvedMenuItem[]) { + const index = menuEntries(items).findIndex((item) => !isDisabled(item)); + return Math.max(0, index); +} + +function lastEnabled(items: ResolvedMenuItem[]) { + const entries = menuEntries(items); + for (let index = entries.length - 1; index >= 0; index -= 1) { + if (!isDisabled(entries[index])) return index; + } + return 0; +} + +function placeSubmenu( + anchor: { left: number; top: number; right: number; bottom: number }, + menu: { width: number; height: number }, + viewport: { left: number; top: number; width: number; height: number }, + margin = 8, +) { + const gap = 4; + const viewportRight = viewport.left + viewport.width; + const viewportBottom = viewport.top + viewport.height; + const fitsRight = anchor.right + gap + menu.width <= viewportRight - margin; + const left = fitsRight + ? anchor.right + gap + : Math.max(viewport.left + margin, anchor.left - menu.width - gap); + const maxTop = Math.max( + viewport.top + margin, + viewportBottom - menu.height - margin, + ); + return { + left, + top: Math.max(viewport.top + margin, Math.min(anchor.top, maxTop)), + maxHeight: Math.max(40, viewport.height - margin * 2), + }; +} + +function separatorKey(items: ResolvedMenuItem[], index: number) { + for (let cursor = index - 1; cursor >= 0; cursor -= 1) { + const item = items[cursor]; + if (item?.type === "command") return `separator-after-${item.command}`; + if (item?.type === "submenu") return `separator-after-${item.label}`; + } + return "separator-start"; +} diff --git a/src/context-menu/native.test.ts b/src/context-menu/native.test.ts new file mode 100644 index 0000000..f46c335 --- /dev/null +++ b/src/context-menu/native.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + type NativeContextMenuViewport, + toLayoutViewportAnchor, +} from "./native"; + +const viewport: NativeContextMenuViewport = { + layoutWidth: 1200, + layoutHeight: 800, + visualWidth: 600, + visualHeight: 400, + visualOffsetLeft: 125, + visualOffsetTop: 75, + visualScale: 2, + deviceScaleFactor: 4, +}; + +describe("native context-menu viewport payload", () => { + it("converts a visual-viewport point into layout coordinates", () => { + expect( + toLayoutViewportAnchor({ kind: "point", x: 20, y: 30 }, viewport), + ).toEqual({ kind: "point", x: 145, y: 105 }); + }); + + it("converts every edge of an anchor rectangle", () => { + expect( + toLayoutViewportAnchor( + { kind: "rect", left: 1, top: 2, right: 31, bottom: 42 }, + viewport, + ), + ).toEqual({ + kind: "rect", + left: 126, + top: 77, + right: 156, + bottom: 117, + }); + }); +}); diff --git a/src/context-menu/native.ts b/src/context-menu/native.ts new file mode 100644 index 0000000..ad6eb74 --- /dev/null +++ b/src/context-menu/native.ts @@ -0,0 +1,49 @@ +import type { ContextMenuAnchor } from "./types"; + +export interface NativeContextMenuViewport { + layoutWidth: number; + layoutHeight: number; + visualWidth: number; + visualHeight: number; + visualOffsetLeft: number; + visualOffsetTop: number; + visualScale: number; + deviceScaleFactor: number; +} + +export function getNativeContextMenuViewport(): NativeContextMenuViewport { + const viewport = window.visualViewport; + return { + layoutWidth: window.innerWidth, + layoutHeight: window.innerHeight, + visualWidth: viewport?.width ?? window.innerWidth, + visualHeight: viewport?.height ?? window.innerHeight, + visualOffsetLeft: viewport?.offsetLeft ?? 0, + visualOffsetTop: viewport?.offsetTop ?? 0, + visualScale: viewport?.scale ?? 1, + deviceScaleFactor: window.devicePixelRatio || 1, + }; +} + +/** + * Mouse events and DOMRects are relative to the visual viewport. Native code + * receives layout-viewport coordinates so it can apply the WebView's actual + * bounds, flipped state, and current visual viewport in one place. + */ +export function toLayoutViewportAnchor( + anchor: ContextMenuAnchor, + viewport: NativeContextMenuViewport, +): ContextMenuAnchor { + const offsetX = viewport.visualOffsetLeft; + const offsetY = viewport.visualOffsetTop; + if (anchor.kind === "point") { + return { kind: "point", x: anchor.x + offsetX, y: anchor.y + offsetY }; + } + return { + kind: "rect", + left: anchor.left + offsetX, + top: anchor.top + offsetY, + right: anchor.right + offsetX, + bottom: anchor.bottom + offsetY, + }; +} diff --git a/src/context-menu/position.test.ts b/src/context-menu/position.test.ts new file mode 100644 index 0000000..2af67f1 --- /dev/null +++ b/src/context-menu/position.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { placeContextMenu } from "./position"; + +const viewport = { left: 0, top: 0, width: 800, height: 600 }; + +describe("placeContextMenu", () => { + it("keeps a point menu inside every viewport edge", () => { + expect( + placeContextMenu( + { kind: "point", x: 790, y: 590 }, + { width: 200, height: 300 }, + viewport, + ), + ).toMatchObject({ left: 590, top: 290 }); + expect( + placeContextMenu( + { kind: "point", x: -20, y: -20 }, + { width: 200, height: 300 }, + viewport, + ), + ).toMatchObject({ left: 8, top: 8 }); + }); + + it("opens a rect menu above and to the left when needed", () => { + expect( + placeContextMenu( + { kind: "rect", left: 750, right: 780, top: 520, bottom: 550 }, + { width: 220, height: 200 }, + viewport, + ), + ).toMatchObject({ left: 560, top: 320 }); + }); + + it("accounts for an offset visual viewport", () => { + const placed = placeContextMenu( + { kind: "point", x: 120, y: 90 }, + { width: 300, height: 200 }, + { left: 100, top: 50, width: 320, height: 240 }, + ); + expect(placed).toMatchObject({ left: 108, top: 58, maxHeight: 224 }); + }); +}); diff --git a/src/context-menu/position.ts b/src/context-menu/position.ts new file mode 100644 index 0000000..d3f6c4f --- /dev/null +++ b/src/context-menu/position.ts @@ -0,0 +1,45 @@ +import type { ContextMenuAnchor } from "./types"; + +export interface MenuViewport { + left: number; + top: number; + width: number; + height: number; +} + +export function placeContextMenu( + anchor: ContextMenuAnchor, + menu: { width: number; height: number }, + viewport: MenuViewport, + margin = 8, +) { + const preferredX = anchor.kind === "point" ? anchor.x : anchor.left; + const preferredY = anchor.kind === "point" ? anchor.y : anchor.bottom; + const alternateX = anchor.kind === "point" ? anchor.x : anchor.right; + const alternateY = anchor.kind === "point" ? anchor.y : anchor.top; + const minX = viewport.left + margin; + const minY = viewport.top + margin; + const maxX = viewport.left + viewport.width - menu.width - margin; + const maxY = viewport.top + viewport.height - menu.height - margin; + let left = preferredX; + let top = preferredY; + if (left > maxX) left = alternateX - menu.width; + if (top > maxY) top = alternateY - menu.height; + return { + left: Math.max(minX, Math.min(left, Math.max(minX, maxX))), + top: Math.max(minY, Math.min(top, Math.max(minY, maxY))), + maxHeight: Math.max(40, viewport.height - margin * 2), + }; +} + +export function currentVisualViewport(): MenuViewport { + const viewport = window.visualViewport; + return viewport + ? { + left: viewport.offsetLeft, + top: viewport.offsetTop, + width: viewport.width, + height: viewport.height, + } + : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; +} diff --git a/src/context-menu/registry.test.ts b/src/context-menu/registry.test.ts new file mode 100644 index 0000000..9f0fd88 --- /dev/null +++ b/src/context-menu/registry.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; +import { + executeContextCommand, + formatShortcut, + registerCommandDefinition, + resolveCommandGroups, + resolveContextMenu, +} from "./registry"; + +describe("context-menu registry", () => { + it("removes empty groups and duplicate separators", () => { + const items = resolveContextMenu( + "text-edit", + { + handlers: { + "edit.copy": { run: vi.fn() }, + "edit.selectAll": { run: vi.fn() }, + }, + }, + "mac", + ); + expect( + items.map((item) => (item.type === "command" ? item.command : item.type)), + ).toEqual(["edit.copy", "separator", "edit.selectAll"]); + }); + + it("resolves labels, state, shortcuts, and checked radio items at invocation time", () => { + const dispose = registerCommandDefinition({ + id: "test.dynamic", + label: "Fallback", + shortcuts: { + mac: { key: "k", modifiers: ["meta", "shift"] }, + windows: { key: "k", modifiers: ["ctrl"] }, + linux: { key: "k", modifiers: ["ctrl"] }, + }, + }); + try { + const items = resolveCommandGroups( + [["test.dynamic"]], + { + handlers: { + "test.dynamic": { + run: vi.fn(), + label: () => "Current label", + checked: () => true, + enabled: () => false, + }, + }, + }, + "mac", + ); + expect(items).toEqual([ + expect.objectContaining({ + type: "command", + label: "Current label", + checked: true, + disabled: true, + shortcutLabel: "⇧⌘K", + }), + ]); + } finally { + dispose(); + } + }); + + it("revalidates enabled and visible state before execution", async () => { + const run = vi.fn(); + let enabled = true; + const target = { + handlers: { + "edit.copy": { run, enabled: () => enabled }, + }, + }; + enabled = false; + expect(await executeContextCommand(target, "edit.copy")).toBe(false); + expect(run).not.toHaveBeenCalled(); + enabled = true; + expect(await executeContextCommand(target, "edit.copy")).toBe(true); + expect(run).toHaveBeenCalledOnce(); + }); + + it("formats platform shortcuts", () => { + expect(formatShortcut({ key: "c", modifiers: ["meta"] }, "mac")).toBe("⌘C"); + expect( + formatShortcut({ key: "F12", modifiers: ["shift"] }, "windows"), + ).toBe("Shift+F12"); + expect( + formatShortcut({ key: "f", modifiers: ["alt", "shift"] }, "mac"), + ).toBe("⇧⌥F"); + }); + + it("keeps Windows and Linux VS Code editor bindings distinct", () => { + const target = { + handlers: { + "edit.redo": { run: vi.fn() }, + "editor.formatDocument": { run: vi.fn() }, + "editor.peekDefinition": { run: vi.fn() }, + }, + }; + const labels = (platform: "windows" | "linux") => + resolveCommandGroups( + [["edit.redo"], ["editor.formatDocument"], ["editor.peekDefinition"]], + target, + platform, + ) + .filter((item) => item.type === "command") + .map((item) => item.shortcutLabel); + + expect(labels("windows")).toEqual(["Ctrl+Y", "Shift+Alt+F", "Alt+F12"]); + expect(labels("linux")).toEqual([ + "Ctrl+Y", + "Ctrl+Shift+I", + "Ctrl+Shift+F10", + ]); + }); +}); diff --git a/src/context-menu/registry.ts b/src/context-menu/registry.ts new file mode 100644 index 0000000..b430c30 --- /dev/null +++ b/src/context-menu/registry.ts @@ -0,0 +1,597 @@ +import { getKeybindingsSnapshot } from "lib/keybindings"; +import { + type DesktopKeyboardCommand, + type DesktopShortcutPlatform, + detectDesktopShortcutPlatform, + formatShortcutBinding, + shortcutForCommand, +} from "workbench/desktopShortcuts"; +import type { + CommandDefinition, + CommandHandler, + CommandId, + CommandShortcut, + CommandTarget, + ContextMenuId, + ResolvedMenuItem, +} from "./types"; + +const DEFINITIONS: CommandDefinition[] = [ + { + id: "edit.undo", + label: "Undo", + icon: "icon-rotate-ccw", + macSymbol: "arrow.uturn.backward", + }, + { + id: "edit.redo", + label: "Redo", + icon: "icon-rotate-cw", + macSymbol: "arrow.uturn.forward", + }, + { + id: "edit.cut", + label: "Cut", + icon: "icon-scissors", + macSymbol: "scissors", + }, + { + id: "edit.copy", + label: "Copy", + icon: "icon-copy", + macSymbol: "doc.on.doc", + }, + { + id: "edit.paste", + label: "Paste", + icon: "icon-clipboard", + macSymbol: "doc.on.clipboard", + }, + { + id: "edit.selectAll", + label: "Select All", + icon: "icon-check-square", + macSymbol: "selection.pin.in.out", + }, + { + id: "editor.definition", + label: "Go to Definition", + icon: "icon-arrow-right", + macSymbol: "arrow.right.to.line", + }, + { + id: "editor.peekDefinition", + label: "Peek Definition", + icon: "icon-eye", + macSymbol: "eye", + }, + { + id: "editor.references", + label: "Go to References", + icon: "icon-list", + macSymbol: "list.bullet", + radio: true, + }, + { + id: "editor.renameSymbol", + label: "Rename Symbol", + icon: "icon-edit-2", + macSymbol: "pencil", + }, + { + id: "editor.formatDocument", + label: "Format Document", + icon: "icon-align-left", + macSymbol: "text.alignleft", + }, + { + id: "editor.openFile", + label: "Open File", + icon: "icon-file-text", + macSymbol: "doc.text", + }, + { + id: "terminal.clear", + label: "Clear", + icon: "icon-trash-2", + macSymbol: "clear", + }, + { + id: "terminal.rename", + label: "Rename Terminal", + icon: "icon-edit-2", + macSymbol: "pencil", + }, + { + id: "terminal.kill", + label: "Kill Terminal", + icon: "icon-x", + macSymbol: "xmark.circle", + danger: true, + }, + { + id: "tab.pin", + label: "Pin Tab", + icon: "icon-pin", + macSymbol: "pin", + }, + { + id: "tab.unpin", + label: "Unpin Tab", + icon: "icon-pin-off", + macSymbol: "pin.slash", + }, + { + id: "tab.moveToPane", + label: "Move to Pane…", + icon: "icon-arrow-right", + macSymbol: "rectangle.portrait.and.arrow.right", + }, + { + id: "tab.splitLeft", + label: "Split Left", + icon: "icon-panel-left", + macSymbol: "rectangle.split.2x1", + }, + { + id: "tab.splitRight", + label: "Split Right", + icon: "icon-panel-right", + macSymbol: "rectangle.split.2x1", + }, + { + id: "tab.splitUp", + label: "Split Up", + icon: "icon-panel-top", + macSymbol: "rectangle.split.1x2", + }, + { + id: "tab.splitDown", + label: "Split Down", + icon: "icon-panel-bottom", + macSymbol: "rectangle.split.1x2", + }, + { + id: "tab.close", + label: "Close", + icon: "icon-x", + macSymbol: "xmark", + }, + { + id: "tab.closeOthers", + label: "Close Others", + icon: "icon-x", + macSymbol: "rectangle.stack.badge.minus", + }, + { + id: "tab.closeRight", + label: "Close to the Right", + icon: "icon-arrow-right", + macSymbol: "arrow.right.to.line", + }, + { + id: "tab.closeAll", + label: "Close All", + icon: "icon-x-circle", + macSymbol: "xmark.rectangle", + }, + { + id: "pane.close", + label: "Close Pane", + icon: "icon-x-square", + macSymbol: "rectangle.badge.xmark", + danger: true, + }, + { + id: "pane.closeTileGroup", + label: "Close Tile Group", + icon: "icon-grid", + macSymbol: "rectangle.split.2x1", + danger: true, + }, + { + id: "resource.open", + label: "Open", + icon: "icon-file-text", + macSymbol: "doc.text", + }, + { + id: "resource.newFile", + label: "New File", + icon: "icon-file-plus", + macSymbol: "doc.badge.plus", + }, + { + id: "resource.newFolder", + label: "New Folder", + icon: "icon-folder-plus", + macSymbol: "folder.badge.plus", + }, + { + id: "resource.copyPath", + label: "Copy Path", + icon: "icon-copy", + macSymbol: "doc.on.doc", + }, + { + id: "resource.copyRelativePath", + label: "Copy Relative Path", + icon: "icon-copy", + macSymbol: "doc.on.doc", + }, + { + id: "resource.reveal", + label: "Reveal in Finder", + icon: "icon-external-link", + macSymbol: "folder", + }, + { + id: "resource.rename", + label: "Rename", + icon: "icon-edit-2", + macSymbol: "pencil", + }, + { + id: "resource.refresh", + label: "Refresh", + icon: "icon-refresh-cw", + macSymbol: "arrow.clockwise", + }, + { + id: "resource.delete", + label: "Delete", + icon: "icon-trash", + macSymbol: "trash", + danger: true, + }, + { + id: "project.search", + label: "Search", + icon: "icon-search", + macSymbol: "magnifyingglass", + }, + { + id: "project.newChat", + label: "New Chat", + icon: "icon-ai-chat", + macSymbol: "bubble.left.and.bubble.right", + }, + { + id: "project.refreshSessions", + label: "Refresh Sessions", + icon: "icon-refresh-cw", + macSymbol: "arrow.clockwise", + }, + { + id: "project.openGit", + label: "Open Git", + icon: "icon-git-branch", + macSymbol: "arrow.triangle.branch", + }, + { + id: "project.openTerminal", + label: "Open in Terminal", + icon: "icon-terminal", + macSymbol: "terminal", + }, + { + id: "project.close", + label: "Close Project", + icon: "icon-x", + macSymbol: "xmark.circle", + }, + { + id: "git.openChanges", + label: "Open Changes", + icon: "icon-git-branch", + macSymbol: "arrow.left.arrow.right", + }, + { + id: "git.stage", + label: "Stage Changes", + icon: "icon-plus", + macSymbol: "plus", + }, + { + id: "git.unstage", + label: "Unstage Changes", + icon: "icon-minus", + macSymbol: "minus", + }, + { + id: "git.discard", + label: "Discard Changes", + icon: "icon-rotate-ccw", + macSymbol: "arrow.uturn.backward", + danger: true, + }, + { + id: "git.listView", + label: "List View", + icon: "icon-list", + macSymbol: "list.bullet", + }, + { + id: "git.treeView", + label: "Tree View", + icon: "icon-account_tree", + macSymbol: "list.bullet.indent", + radio: true, + }, + { + id: "git.switchBranch", + label: "Switch Branch…", + icon: "icon-git-branch", + macSymbol: "arrow.triangle.branch", + }, + { + id: "git.pull", + label: "Pull", + icon: "icon-arrow-down", + macSymbol: "arrow.down", + }, + { + id: "git.push", + label: "Push", + icon: "icon-upload-cloud", + macSymbol: "arrow.up", + }, + { + id: "git.fetch", + label: "Fetch", + icon: "icon-download-cloud", + macSymbol: "arrow.down.circle", + }, + { + id: "git.history", + label: "Open History", + icon: "icon-list", + macSymbol: "clock.arrow.circlepath", + }, +]; + +const definitions = new Map( + DEFINITIONS.map((definition) => [definition.id, definition]), +); + +const KEYBINDING_COMMANDS: Partial> = + { + "edit.undo": "undo", + "edit.redo": "redo", + "edit.cut": "cut", + "edit.copy": "copy", + "edit.paste": "paste", + "edit.selectAll": "select-all", + "editor.definition": "editor.definition", + "editor.peekDefinition": "editor.peekDefinition", + "editor.references": "editor.references", + "editor.renameSymbol": "editor.renameSymbol", + "editor.formatDocument": "editor.formatDocument", + "tab.close": "close-tab", + }; + +const MENUS: Record = { + "text-edit": [ + ["edit.undo", "edit.redo"], + ["edit.cut", "edit.copy", "edit.paste"], + ["edit.selectAll"], + ], + "text-selection": [["edit.copy"]], + editor: [ + [ + "editor.definition", + "editor.peekDefinition", + "editor.references", + "editor.renameSymbol", + "editor.formatDocument", + ], + ["edit.undo", "edit.redo"], + ["edit.cut", "edit.copy", "edit.paste"], + ["edit.selectAll"], + ], + "editor-diff": [["edit.copy", "edit.selectAll"], ["editor.openFile"]], + terminal: [ + ["edit.copy", "edit.paste"], + ["edit.selectAll", "terminal.clear"], + ["terminal.rename", "terminal.kill"], + ], + "workbench-tab": [ + ["tab.pin", "tab.unpin"], + [ + "tab.moveToPane", + "tab.splitLeft", + "tab.splitRight", + "tab.splitUp", + "tab.splitDown", + ], + ["tab.close", "tab.closeOthers", "tab.closeRight", "tab.closeAll"], + ["pane.close", "pane.closeTileGroup"], + ["resource.copyPath", "resource.reveal"], + ], + "project-tree-file": [ + ["resource.open"], + ["resource.copyPath", "resource.copyRelativePath", "resource.reveal"], + ["resource.rename", "resource.refresh"], + ["resource.delete"], + ], + "project-tree-directory": [ + ["resource.newFile", "resource.newFolder"], + ["resource.copyPath", "resource.copyRelativePath", "resource.reveal"], + ["resource.rename", "resource.refresh"], + ["resource.delete"], + ], + "git-change": [ + ["git.openChanges", "resource.open"], + ["git.stage", "git.unstage", "git.discard"], + ["resource.copyPath", "resource.copyRelativePath", "resource.reveal"], + ], + "git-group": [["git.stage", "git.unstage", "git.discard"]], + "git-repository": [ + ["git.listView", "git.treeView"], + ["resource.refresh", "git.switchBranch"], + ["git.pull", "git.push", "git.fetch"], + ["git.history"], + ], + "project-pane": [], + "action-menu": [], +}; + +export function registerCommandDefinition(definition: CommandDefinition) { + definitions.set(definition.id, definition); + return () => { + if (definitions.get(definition.id) === definition) + definitions.delete(definition.id); + }; +} + +export function resolveContextMenu( + menuId: ContextMenuId, + target: CommandTarget, + platform = browserPlatform(), +): ResolvedMenuItem[] { + const groups = MENUS[menuId] ?? []; + const resolvedGroups = groups + .map((group) => + group.flatMap((id) => resolveCommand(id, target.handlers[id], platform)), + ) + .filter((group) => group.length > 0); + return resolvedGroups.flatMap((group, index) => + index === 0 ? group : [{ type: "separator" } as const, ...group], + ); +} + +export function resolveCommandGroups( + groups: readonly (readonly CommandId[])[], + target: CommandTarget, + platform = browserPlatform(), +): ResolvedMenuItem[] { + const resolved = groups + .map((group) => + group.flatMap((id) => resolveCommand(id, target.handlers[id], platform)), + ) + .filter((group) => group.length > 0); + return resolved.flatMap((group, index) => + index === 0 ? group : [{ type: "separator" } as const, ...group], + ); +} + +export function resolveCommandItems( + ids: readonly CommandId[], + target: CommandTarget, + platform = browserPlatform(), +) { + return ids.flatMap((id) => resolveCommand(id, target.handlers[id], platform)); +} + +function resolveCommand( + id: CommandId, + handler: CommandHandler | undefined, + platform: DesktopShortcutPlatform, +): ResolvedMenuItem[] { + const definition = definitions.get(id); + if (!definition || !handler || !evaluate(handler.visible, true)) return []; + const keybindingCommand = KEYBINDING_COMMANDS[id]; + const keybinding = keybindingCommand + ? shortcutForCommand( + keybindingCommand, + platform, + getKeybindingsSnapshot().overrides, + ) + : undefined; + const shortcutValue = + keybinding?.strokes.length === 1 + ? { + key: keybinding.strokes[0].key, + modifiers: keybinding.strokes[0].modifiers, + } + : definition.shortcuts?.[platform]; + return [ + { + type: "command", + command: id, + label: evaluateValue(handler.label, definition.label), + icon: definition.icon, + macSymbol: definition.macSymbol, + shortcut: shortcutValue, + shortcutLabel: keybinding + ? formatShortcutBinding(keybinding, platform) + : shortcutValue + ? formatShortcut(shortcutValue, platform) + : undefined, + danger: definition.danger, + disabled: !evaluate(handler.enabled, true), + checked: + handler.checked === undefined + ? undefined + : evaluate(handler.checked, false), + radio: definition.radio, + }, + ]; +} + +export async function executeContextCommand( + target: CommandTarget, + command: CommandId, +) { + const handler = target.handlers[command]; + if ( + !handler || + !evaluate(handler.visible, true) || + !evaluate(handler.enabled, true) + ) + return false; + await handler.run(); + return true; +} + +export function formatShortcut( + shortcutValue: CommandShortcut, + platform: "mac" | "windows" | "linux", +) { + const requested = new Set(shortcutValue.modifiers ?? []); + const modifiers = (["ctrl", "shift", "alt", "meta"] as const).filter( + (modifier) => requested.has(modifier), + ); + if (platform === "mac") { + const symbols = { ctrl: "⌃", alt: "⌥", shift: "⇧", meta: "⌘" } as const; + return `${modifiers.map((modifier) => symbols[modifier]).join("")}${shortcutValue.key.length === 1 ? shortcutValue.key.toUpperCase() : shortcutValue.key}`; + } + const names = { + ctrl: "Ctrl", + alt: "Alt", + shift: "Shift", + meta: "Meta", + } as const; + return [ + ...modifiers.map((modifier) => names[modifier]), + shortcutValue.key.length === 1 + ? shortcutValue.key.toUpperCase() + : shortcutValue.key, + ].join("+"); +} + +function evaluate( + value: boolean | (() => boolean) | undefined, + fallback: boolean, +) { + try { + return typeof value === "function" ? value() : (value ?? fallback); + } catch { + return false; + } +} + +function evaluateValue( + value: string | (() => string) | undefined, + fallback: string, +) { + try { + return typeof value === "function" ? value() : (value ?? fallback); + } catch { + return fallback; + } +} + +function browserPlatform(): "mac" | "windows" | "linux" { + return detectDesktopShortcutPlatform(); +} diff --git a/src/context-menu/service.test.ts b/src/context-menu/service.test.ts new file mode 100644 index 0000000..c100b3b --- /dev/null +++ b/src/context-menu/service.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + contextMenuTriggerForEvent, + dismissContextMenu, + getContextMenuSnapshot, + selectContextMenuCommand, + showContextMenu, +} from "./service"; + +afterEach(() => dismissContextMenu(false)); + +describe("browser context-menu service", () => { + it("distinguishes pointer and keyboard context-menu events", () => { + expect( + contextMenuTriggerForEvent( + new MouseEvent("contextmenu", { button: 2, clientX: 40, clientY: 20 }), + ), + ).toBe("context"); + expect( + contextMenuTriggerForEvent( + new MouseEvent("contextmenu", { button: 0, ctrlKey: true }), + ), + ).toBe("context"); + expect(contextMenuTriggerForEvent(new MouseEvent("contextmenu"))).toBe( + "keyboard", + ); + }); + + it("retains the invocation target until selection", async () => { + const run = vi.fn(); + const completion = showContextMenu({ + menuId: "text-selection", + anchor: { kind: "point", x: 10, y: 20 }, + trigger: "context", + target: { handlers: { "edit.copy": { run } } }, + }); + const snapshot = getContextMenuSnapshot(); + expect(snapshot?.items).toEqual([ + expect.objectContaining({ command: "edit.copy", label: "Copy" }), + ]); + expect( + await selectContextMenuCommand(snapshot?.id ?? -1, "edit.copy"), + ).toBe(true); + expect(await completion).toBe(true); + expect(run).toHaveBeenCalledOnce(); + }); + + it("cancels superseded invocations and ignores stale selections", async () => { + const firstRun = vi.fn(); + const firstCompletion = showContextMenu({ + menuId: "text-selection", + anchor: { kind: "point", x: 1, y: 1 }, + trigger: "context", + target: { handlers: { "edit.copy": { run: firstRun } } }, + }); + const firstId = getContextMenuSnapshot()?.id ?? -1; + const secondCompletion = showContextMenu({ + menuId: "text-selection", + anchor: { kind: "point", x: 2, y: 2 }, + trigger: "context", + target: { handlers: { "edit.copy": { run: vi.fn() } } }, + }); + expect(await firstCompletion).toBe(false); + expect(await selectContextMenuCommand(firstId, "edit.copy")).toBe(false); + expect(firstRun).not.toHaveBeenCalled(); + dismissContextMenu(false); + expect(await secondCompletion).toBe(false); + }); + + it("revalidates enabled state after the menu is shown", async () => { + let enabled = true; + const run = vi.fn(); + const completion = showContextMenu({ + menuId: "text-selection", + anchor: { kind: "point", x: 0, y: 0 }, + trigger: "keyboard", + target: { + handlers: { + "edit.copy": { run, enabled: () => enabled }, + }, + }, + }); + const id = getContextMenuSnapshot()?.id ?? -1; + enabled = false; + expect(await selectContextMenuCommand(id, "edit.copy")).toBe(false); + expect(await completion).toBe(false); + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/src/context-menu/service.ts b/src/context-menu/service.ts new file mode 100644 index 0000000..d088e3d --- /dev/null +++ b/src/context-menu/service.ts @@ -0,0 +1,172 @@ +import { + cancelNativeContextMenu, + showNativeContextMenu, +} from "bridge/contextMenu"; +import toast from "lib/toast"; +import type { MouseEvent as ReactMouseEvent } from "react"; +import { getNativeContextMenuViewport, toLayoutViewportAnchor } from "./native"; +import { + executeContextCommand, + resolveCommandGroups, + resolveContextMenu, +} from "./registry"; +import type { + CommandId, + ContextMenuInvocation, + ResolvedMenuItem, +} from "./types"; + +export interface BrowserContextMenuSnapshot { + id: number; + invocation: ContextMenuInvocation; + items: ResolvedMenuItem[]; +} + +let nextId = 0; +let snapshot: BrowserContextMenuSnapshot | null = null; +const browserCompletions = new Map void>(); +const listeners = new Set<() => void>(); + +export function subscribeContextMenu(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getContextMenuSnapshot() { + return snapshot; +} + +export async function showContextMenu(invocation: ContextMenuInvocation) { + const items = invocation.commandGroups + ? resolveCommandGroups(invocation.commandGroups, invocation.target) + : resolveContextMenu(invocation.menuId, invocation.target); + return presentContextMenu(invocation, items); +} + +export async function showResolvedContextMenu( + invocation: ContextMenuInvocation, + items: ResolvedMenuItem[], +) { + return presentContextMenu(invocation, items); +} + +async function presentContextMenu( + invocation: ContextMenuInvocation, + items: ResolvedMenuItem[], +) { + if (items.length === 0) return false; + const id = ++nextId; + if (process.env.IS_MACOS) { + try { + const viewport = getNativeContextMenuViewport(); + const command = await showNativeContextMenu({ + id, + trigger: invocation.trigger, + anchor: toLayoutViewportAnchor(invocation.anchor, viewport), + viewport, + items, + }); + if (id !== nextId || !command) return false; + return await executeSafely(invocation, command); + } catch (error) { + console.error("Native context menu failed", error); + toast("Context menu is unavailable", 3000); + return false; + } + } + if (snapshot) dismissContextMenu(false); + snapshot = { id, invocation, items }; + emit(); + return new Promise((resolve) => { + browserCompletions.set(id, resolve); + }); +} + +export function showContextMenuForEvent( + event: MouseEvent | ReactMouseEvent, + invocation: Omit, +) { + event.preventDefault(); + event.stopPropagation(); + const nativeEvent = "nativeEvent" in event ? event.nativeEvent : event; + const target = + nativeEvent.target instanceof HTMLElement ? nativeEvent.target : null; + const bounds = target?.getBoundingClientRect(); + const anchor = + nativeEvent.clientX || nativeEvent.clientY || !bounds + ? { + kind: "point" as const, + x: nativeEvent.clientX, + y: nativeEvent.clientY, + } + : { + kind: "rect" as const, + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + }; + return showContextMenu({ + ...invocation, + anchor, + trigger: contextMenuTriggerForEvent(nativeEvent), + origin: target, + }); +} + +export function contextMenuTriggerForEvent(event: MouseEvent) { + return event.button !== 2 && + !event.ctrlKey && + !event.clientX && + !event.clientY + ? ("keyboard" as const) + : ("context" as const); +} + +export async function selectContextMenuCommand(id: number, command: CommandId) { + if (!snapshot || snapshot.id !== id) return false; + const invocation = snapshot.invocation; + const completion = browserCompletions.get(id); + browserCompletions.delete(id); + snapshot = null; + nextId += 1; + emit(); + const executed = await executeSafely(invocation, command); + completion?.(executed); + if (invocation.origin?.isConnected) { + invocation.origin.focus({ preventScroll: true }); + } + return executed; +} + +export function dismissContextMenu(restoreFocus = true) { + nextId += 1; + const previous = snapshot; + snapshot = null; + if (previous) emit(); + if (previous) { + browserCompletions.get(previous.id)?.(false); + browserCompletions.delete(previous.id); + } + if (process.env.IS_MACOS) void cancelNativeContextMenu().catch(() => {}); + if (restoreFocus && previous?.invocation.origin?.isConnected) { + previous.invocation.origin.focus({ preventScroll: true }); + } +} + +async function executeSafely( + invocation: ContextMenuInvocation, + command: CommandId, +) { + try { + return await executeContextCommand(invocation.target, command); + } catch (error) { + console.error(`Context command ${command} failed`, error); + toast("The command could not be completed", 3000); + return false; + } +} + +function emit() { + for (const listener of listeners) listener(); +} diff --git a/src/context-menu/types.ts b/src/context-menu/types.ts new file mode 100644 index 0000000..23f8b9d --- /dev/null +++ b/src/context-menu/types.ts @@ -0,0 +1,98 @@ +export type ContextMenuId = + | "text-edit" + | "text-selection" + | "editor" + | "editor-diff" + | "terminal" + | "workbench-tab" + | "project-pane" + | "project-tree-file" + | "project-tree-directory" + | "git-repository" + | "git-change" + | "git-group" + | "action-menu"; + +export type CommandId = string; + +export type ContextMenuTrigger = "context" | "button" | "keyboard"; + +export interface CommandShortcut { + key: string; + modifiers?: Array<"meta" | "ctrl" | "alt" | "shift">; +} + +export interface PlatformShortcuts { + mac?: CommandShortcut; + windows?: CommandShortcut; + linux?: CommandShortcut; +} + +export interface CommandDefinition { + id: CommandId; + label: string; + icon?: string; + macSymbol?: string; + shortcuts?: PlatformShortcuts; + danger?: boolean; + radio?: boolean; +} + +export interface CommandHandler { + run: () => unknown | Promise; + enabled?: boolean | (() => boolean); + visible?: boolean | (() => boolean); + checked?: boolean | (() => boolean); + label?: string | (() => string); +} + +export interface CommandTarget { + handlers: Record; +} + +export type ContextMenuAnchor = + | { kind: "point"; x: number; y: number } + | { + kind: "rect"; + left: number; + top: number; + right: number; + bottom: number; + }; + +export type ResolvedMenuItem = + | { type: "separator" } + | { + type: "command"; + command: CommandId; + label: string; + icon?: string; + macSymbol?: string; + shortcut?: CommandShortcut; + shortcutLabel?: string; + danger?: boolean; + disabled?: boolean; + checked?: boolean; + radio?: boolean; + } + | { + type: "submenu"; + label: string; + items: ResolvedMenuItem[]; + icon?: string; + macSymbol?: string; + }; + +export interface ContextMenuInvocation { + menuId: ContextMenuId; + commandGroups?: readonly (readonly CommandId[])[]; + target: CommandTarget; + anchor: ContextMenuAnchor; + trigger: ContextMenuTrigger; + origin?: HTMLElement | null; +} + +export interface ContextMenuPresenter { + show(invocation: ContextMenuInvocation): Promise; + dismiss(): void; +} diff --git a/src/index.d.ts b/src/index.d.ts index 667331b..cfea6e7 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -14,10 +14,12 @@ declare namespace NodeJS { interface Process { env: { ID: string; + DEV_SERVER?: string; IS_IOS: boolean; IS_MACOS: boolean; IS_ANDROID: boolean; IS_BROWSER: boolean; + IS_DESKTOP_UI: boolean; DEV_MODE: boolean; PLATFORM: PLATFORM; VERSION: string; diff --git a/src/lib/appConfig.ts b/src/lib/appConfig.ts index dd07fa0..53f1b98 100644 --- a/src/lib/appConfig.ts +++ b/src/lib/appConfig.ts @@ -1,6 +1,15 @@ +(() => { + setTimeout(() => { + console.log('dev mode: ', process.env.DEV_MODE); + console.log(process.env.DEV_SERVER); + }, 100); +})(); + export default { DATA_DIR: "/files", CACHE_DIR: "/cache", BROWSER_CACHE_NAME: "app-cache-v1", - DEFAULT_SERVER: "api.shellular.dev", + DEFAULT_SERVER: process.env.DEV_MODE + ? (process.env.DEV_SERVER ?? "server.shellular.dev") + : "server.shellular.dev", }; diff --git a/src/lib/auth.test.tsx b/src/lib/auth.test.tsx new file mode 100644 index 0000000..5596132 --- /dev/null +++ b/src/lib/auth.test.tsx @@ -0,0 +1,111 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + secureGet: vi.fn(), + secureSet: vi.fn(), + secureRemove: vi.fn(), + fetch: vi.fn(), +})); + +vi.mock("bridge/browser", () => ({ + default: { openForAuth: vi.fn() }, +})); +vi.mock("bridge/native", () => ({ + default: { getAppInfo: vi.fn() }, +})); +vi.mock("bridge/secureStore", () => ({ + default: { + get: mocks.secureGet, + set: mocks.secureSet, + remove: mocks.secureRemove, + }, +})); +vi.mock("lib/settings", () => ({ + getBaseServerUrl: vi.fn(async () => "https://server.shellular.dev"), +})); + +import { AuthProvider, getAuthenticatedUserForAuth, useAuth } from "./auth"; + +const user = { + id: "user_123", + email: "developer@example.com", + name: "Developer", + avatarUrl: null, + linkedAccounts: [], +}; + +function Probe() { + const { status, logout } = useAuth(); + return ( + <> + {status} + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("PLATFORM", "macos"); + mocks.secureGet.mockResolvedValue("refresh-token"); + mocks.secureSet.mockResolvedValue(undefined); + mocks.secureRemove.mockResolvedValue(undefined); + mocks.fetch.mockImplementation(async (input: RequestInfo | URL) => { + const path = new URL(String(input)).pathname; + const data = + path === "/auth/providers" + ? { providers: [] } + : path === "/auth/refresh" + ? { + accessToken: "access-token", + accessTokenExpiresAt: Date.now() + 3_600_000, + refreshToken: "next-refresh-token", + refreshTokenExpiresAt: Date.now() + 7_200_000, + user, + } + : {}; + return { + ok: true, + json: async () => ({ success: true, data }), + } as Response; + }); + vi.stubGlobal("fetch", mocks.fetch); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("authenticated user snapshot", () => { + it("tracks the verified session and clears it on logout", async () => { + render( + + + , + ); + + await screen.findByText("authenticated"); + expect(getAuthenticatedUserForAuth()).toEqual({ + id: user.id, + email: user.email, + }); + + fireEvent.click(screen.getByRole("button", { name: "Logout" })); + await waitFor(() => + expect(screen.getByText("unauthenticated")).toBeVisible(), + ); + expect(getAuthenticatedUserForAuth()).toBeNull(); + expect(mocks.secureRemove).toHaveBeenCalledWith("auth-refresh-token"); + }); +}); diff --git a/src/lib/auth.tsx b/src/lib/auth.tsx index 6ff88ff..9de71f9 100644 --- a/src/lib/auth.tsx +++ b/src/lib/auth.tsx @@ -67,6 +67,21 @@ let refreshTokenValue: string | null = null; let refreshInFlight: Promise | null = null; let authCallbackSchemeInFlight: Promise | null = null; let activeBrowserAuthRequestId: string | null = null; +let authenticatedUserSnapshot: AuthUser | null = null; + +function setAuthenticatedUserSnapshot(user: AuthUser | null): void { + authenticatedUserSnapshot = user; +} + +export function getAuthenticatedUserForAuth(): Readonly< + Pick +> | null { + if (!authenticatedUserSnapshot) return null; + return { + id: authenticatedUserSnapshot.id, + email: authenticatedUserSnapshot.email, + }; +} function isBrowserCookieAuth(): boolean { return process.env.PLATFORM === "browser"; @@ -83,26 +98,33 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const [signingInProvider, setSigningInProvider] = useState(null); + const updateUser = useCallback((nextUser: AuthUser | null) => { + setAuthenticatedUserSnapshot(nextUser); + setUser(nextUser); + }, []); const clearAuth = useCallback(async () => { accessToken = null; accessTokenExpiresAt = 0; refreshTokenValue = null; await secureStore.remove(REFRESH_TOKEN_KEY); - setUser(null); + updateUser(null); setStatus("unauthenticated"); - }, []); + }, [updateUser]); - const applyTokenResponse = useCallback(async (data: TokenResponse) => { - accessToken = data.accessToken; - accessTokenExpiresAt = data.accessTokenExpiresAt; - refreshTokenValue = data.refreshToken; - await secureStore.set(REFRESH_TOKEN_KEY, data.refreshToken); - setUser(data.user); - setStatus("authenticated"); - setError(null); - setAccountError(null); - }, []); + const applyTokenResponse = useCallback( + async (data: TokenResponse) => { + accessToken = data.accessToken; + accessTokenExpiresAt = data.accessTokenExpiresAt; + refreshTokenValue = data.refreshToken; + await secureStore.set(REFRESH_TOKEN_KEY, data.refreshToken); + updateUser(data.user); + setStatus("authenticated"); + setError(null); + setAccountError(null); + }, + [updateUser], + ); const refresh = useCallback(async () => { if (refreshInFlight) return refreshInFlight; @@ -118,7 +140,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { accessToken = null; accessTokenExpiresAt = data.accessTokenExpiresAt; refreshTokenValue = null; - setUser(data.user); + updateUser(data.user); setStatus("authenticated"); setError(null); setAccountError(null); @@ -156,7 +178,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } })(); return refreshInFlight; - }, [applyTokenResponse, clearAuth]); + }, [applyTokenResponse, clearAuth, updateUser]); const ensureAccessToken = useCallback(async () => { if (accessToken && accessTokenExpiresAt - Date.now() > REFRESH_SKEW_MS) { @@ -282,7 +304,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const refreshMe = useCallback(async () => { if (isBrowserCookieAuth()) { const data = await authRequest<{ user: AuthUser }>("/auth/me"); - setUser(data.user); + updateUser(data.user); setAccountError(null); return; } @@ -291,9 +313,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const data = await authRequest<{ user: AuthUser }>("/auth/me", { headers: { Authorization: `Bearer ${token}` }, }); - setUser(data.user); + updateUser(data.user); setAccountError(null); - }, [ensureAccessToken]); + }, [ensureAccessToken, updateUser]); const linkAccount = useCallback( async (provider: AuthProviderId) => { @@ -342,7 +364,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { body: JSON.stringify({ code: params.linkCode }), }, ); - setUser(data.user); + updateUser(data.user); } catch (err) { const message = errorMessage(err); if (!isAuthSuperseded(message)) { @@ -353,7 +375,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setAccountAction(null); } }, - [ensureAccessToken, refreshMe], + [ensureAccessToken, refreshMe, updateUser], ); const unlinkAccount = useCallback( @@ -369,7 +391,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { headers: token ? { Authorization: `Bearer ${token}` } : undefined, }, ); - setUser(data.user); + updateUser(data.user); } catch (err) { logAuthError(`unlink ${provider} account`, err); setAccountError("We couldn't unlink this account. Please try again."); @@ -377,7 +399,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setAccountAction(null); } }, - [ensureAccessToken], + [ensureAccessToken, updateUser], ); const logout = useCallback(async () => { @@ -492,6 +514,7 @@ async function refreshWithoutReact(): Promise { accessToken = null; accessTokenExpiresAt = data.accessTokenExpiresAt; refreshTokenValue = null; + setAuthenticatedUserSnapshot(data.user); return Boolean(data.user); } @@ -502,12 +525,14 @@ async function refreshWithoutReact(): Promise { accessToken = data.accessToken; accessTokenExpiresAt = data.accessTokenExpiresAt; refreshTokenValue = data.refreshToken; + setAuthenticatedUserSnapshot(data.user); await secureStore.set(REFRESH_TOKEN_KEY, data.refreshToken); return true; } catch { accessToken = null; accessTokenExpiresAt = 0; refreshTokenValue = null; + setAuthenticatedUserSnapshot(null); await secureStore.remove(REFRESH_TOKEN_KEY); return false; } finally { diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index 70407ff..10660b3 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -1,3 +1,4 @@ +import native from "bridge/native"; import toast from "lib/toast"; interface CopyToClipboardOptions { @@ -12,7 +13,13 @@ export async function copyToClipboard({ errorMessage = "Failed to copy", }: CopyToClipboardOptions) { try { - await navigator.clipboard.writeText(text); + if (process.env.IS_MACOS) { + await native.writeClipboardText(text); + } else if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + throw new Error("Clipboard API is unavailable"); + } if (!process.env.IS_ANDROID && successMessage) { toast(successMessage); } @@ -37,3 +44,10 @@ export async function copyToClipboard({ } } } + +export async function readFromClipboard() { + if (process.env.IS_MACOS) return native.readClipboardText(); + if (!navigator.clipboard?.readText) + throw new Error("Clipboard API is unavailable"); + return navigator.clipboard.readText(); +} diff --git a/src/lib/codemirrorAppTheme.test.ts b/src/lib/codemirrorAppTheme.test.ts new file mode 100644 index 0000000..cc25b00 --- /dev/null +++ b/src/lib/codemirrorAppTheme.test.ts @@ -0,0 +1,12 @@ +import themes, { themeOptions } from "themes"; +import { describe, expect, it } from "vitest"; +import { createCodeMirrorAppTheme } from "./codemirrorAppTheme"; + +describe("Shellular CodeMirror themes", () => { + it("creates an app and syntax extension for every registered theme", async () => { + for (const option of themeOptions) { + const theme = await themes.get(option.id); + expect(createCodeMirrorAppTheme(theme)).toBeTruthy(); + } + }); +}); diff --git a/src/lib/codemirrorAppTheme.ts b/src/lib/codemirrorAppTheme.ts new file mode 100644 index 0000000..22517fa --- /dev/null +++ b/src/lib/codemirrorAppTheme.ts @@ -0,0 +1,106 @@ +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { type Extension, Prec } from "@codemirror/state"; +import { tags as t } from "@lezer/highlight"; +import type Theme from "classes/theme"; +import { EditorView } from "codemirror"; +import { withColorAlpha } from "./monacoColor"; + +export function createCodeMirrorAppTheme(theme: Theme): Extension { + const dark = theme.type !== "light"; + const syntax = theme.syntaxTheme; + const viewTheme = EditorView.theme( + { + "&": { + backgroundColor: theme.primary, + backgroundImage: "none", + color: theme.primaryText, + }, + ".cm-scroller": { + backgroundColor: theme.primary, + color: theme.primaryText, + }, + ".cm-gutters, .cm-changeGutter": { + backgroundColor: theme.secondary, + borderColor: theme.cardBorder, + color: theme.textMuted, + }, + ".cm-activeLine": { backgroundColor: theme.surfaceSoft }, + ".cm-activeLineGutter": { + backgroundColor: theme.surfaceSoft, + color: theme.primaryText, + }, + ".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.accent }, + ".cm-selectionBackground, &.cm-focused .cm-selectionBackground, .cm-content ::selection": + { + backgroundColor: `${withColorAlpha( + theme.accent, + dark ? 0.24 : 0.2, + )} !important`, + }, + ".cm-panels, .cm-tooltip": { + background: theme.popupBackground, + borderColor: theme.popupBorderColor, + color: theme.popupText, + }, + ".cm-textfield, .cm-panel.cm-search input": { + backgroundColor: theme.input, + borderColor: theme.cardBorder, + color: theme.inputText, + }, + ".cm-button, .cm-panel.cm-search button": { + backgroundColor: theme.surfaceSoft, + borderColor: theme.cardBorder, + color: theme.primaryText, + }, + ".cm-foldPlaceholder": { + backgroundColor: theme.surfaceStrong, + borderColor: theme.cardBorder, + color: theme.accent, + }, + ".cm-mergeView": { + backgroundColor: theme.primary, + color: theme.primaryText, + }, + ".cm-mergeViewEditors": { borderColor: theme.cardBorder }, + }, + { dark }, + ); + const highlight = HighlightStyle.define( + [ + { + tag: [t.comment, t.lineComment, t.blockComment, t.docComment], + color: syntax.comment, + fontStyle: "italic", + }, + { + tag: [t.keyword, t.controlKeyword, t.operatorKeyword, t.modifier], + color: syntax.keyword, + }, + { tag: [t.operator, t.punctuation], color: syntax.operator }, + { tag: [t.string, t.special(t.string), t.regexp], color: syntax.string }, + { tag: [t.number, t.bool, t.null], color: syntax.number }, + { tag: [t.propertyName, t.attributeName], color: syntax.property }, + { tag: [t.className, t.typeName, t.namespace], color: syntax.type }, + { tag: [t.tagName], color: syntax.tag }, + { + tag: [t.function(t.variableName), t.function(t.propertyName)], + color: syntax.function, + }, + { + tag: [t.constant(t.name), t.standard(t.name)], + color: syntax.constant, + }, + { + tag: [t.variableName, t.definition(t.variableName)], + color: syntax.variable, + }, + { + tag: [t.invalid], + color: theme.danger, + textDecoration: "underline wavy", + }, + ], + { themeType: dark ? "dark" : "light" }, + ); + return Prec.high([viewTheme, syntaxHighlighting(highlight)]); +} diff --git a/src/lib/diffsTheme.test.ts b/src/lib/diffsTheme.test.ts new file mode 100644 index 0000000..00f51b5 --- /dev/null +++ b/src/lib/diffsTheme.test.ts @@ -0,0 +1,33 @@ +import themes, { themeOptions } from "themes"; +import { describe, expect, it } from "vitest"; +import { createDiffTheme, diffThemeNameFromId } from "./diffsTheme"; + +describe("Shellular diff themes", () => { + it("creates a registered-name-compatible Shiki theme for every app theme", async () => { + for (const option of themeOptions) { + const theme = await themes.get(option.id); + const diffTheme = createDiffTheme(theme); + + expect(diffTheme.name).toBe(diffThemeNameFromId(option.id)); + expect(diffTheme.type).toBe(option.type); + expect(diffTheme.colors["editor.background"]).toBe(theme.primary); + expect(diffTheme.colors["editor.foreground"]).toBe(theme.primaryText); + expect(diffTheme.tokenColors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: expect.arrayContaining(["keyword"]), + settings: expect.objectContaining({ + foreground: theme.syntaxTheme.keyword, + }), + }), + expect.objectContaining({ + scope: expect.arrayContaining(["string"]), + settings: expect.objectContaining({ + foreground: theme.syntaxTheme.string, + }), + }), + ]), + ); + } + }); +}); diff --git a/src/lib/diffsTheme.ts b/src/lib/diffsTheme.ts index 66dce82..74369d1 100644 --- a/src/lib/diffsTheme.ts +++ b/src/lib/diffsTheme.ts @@ -1,174 +1,117 @@ import { registerCustomTheme } from "@pierre/diffs"; +import type Theme from "classes/theme"; +import themes from "themes"; +import { toThemeId } from "themes/id"; +import { withColorAlpha } from "./monacoColor"; let registered = false; -const shellularDarkTheme = { - name: "shellular-champagne-noir", - type: "dark" as const, - colors: { - "editor.background": "#0f1013", - "editor.foreground": "#f6f2e9", - "editor.selectionBackground": "#d6c2a13d", - "editor.lineHighlightBackground": "#f5f1e808", - "editorLineNumber.foreground": "#80786d", - "editorCursor.foreground": "#d6c2a1", - }, - tokenColors: [ - { - scope: ["comment", "punctuation.definition.comment"], - settings: { foreground: "#80786d", fontStyle: "italic" }, - }, - { - scope: ["keyword", "storage", "storage.type", "constant.language"], - settings: { foreground: "#d6c2a1", fontStyle: "bold" }, - }, - { - scope: [ - "keyword.operator", - "punctuation.separator", - "punctuation.terminator", - ], - settings: { foreground: "#8fb7b3" }, - }, - { - scope: ["string", "string.quoted", "constant.character"], - settings: { foreground: "#91c58a" }, - }, - { - scope: [ - "constant.numeric", - "constant.language.boolean", - "constant.language.null", - ], - settings: { foreground: "#e8d8bc" }, - }, - { - scope: ["entity.name.function", "support.function", "variable.function"], - settings: { foreground: "#a7c1d9" }, - }, - { - scope: [ - "entity.name.type", - "entity.name.class", - "support.type", - "support.class", - ], - settings: { foreground: "#e8d8bc" }, - }, - { - scope: ["entity.name.tag", "support.type.property-name"], - settings: { foreground: "#d6c2a1" }, - }, - { - scope: ["entity.other.attribute-name", "variable.other.property"], - settings: { foreground: "#ddd4c6" }, - }, - { - scope: ["variable", "identifier"], - settings: { foreground: "#ddd4c6" }, - }, - { - scope: ["regexp", "constant.character.escape"], - settings: { foreground: "#8fb7b3" }, - }, - { - scope: ["markup.heading", "markup.bold"], - settings: { foreground: "#d6c2a1", fontStyle: "bold" }, - }, - { - scope: ["markup.italic", "markup.quote"], - settings: { foreground: "#ddd4c6", fontStyle: "italic" }, - }, - { - scope: ["invalid", "invalid.illegal"], - settings: { foreground: "#c96b5c", fontStyle: "underline" }, - }, - ], -}; +export function diffThemeName(theme: Pick): string { + return `shellular-${toThemeId(theme.name)}`; +} -const shellularLightTheme = { - name: "shellular-champagne-light", - type: "light" as const, - colors: { - "editor.background": "#fffaf1", - "editor.foreground": "#231f1a", - "editor.selectionBackground": "#8a6f4d33", - "editor.lineHighlightBackground": "#231f1a08", - "editorLineNumber.foreground": "#8a8176", - "editorCursor.foreground": "#8a6f4d", - }, - tokenColors: [ - { - scope: ["comment", "punctuation.definition.comment"], - settings: { foreground: "#8a8176", fontStyle: "italic" }, - }, - { - scope: ["keyword", "storage", "storage.type", "constant.language"], - settings: { foreground: "#8a6f4d", fontStyle: "bold" }, - }, - { - scope: [ - "keyword.operator", - "punctuation.separator", - "punctuation.terminator", - ], - settings: { foreground: "#2f6f73" }, - }, - { - scope: ["string", "string.quoted", "constant.character"], - settings: { foreground: "#52785a" }, - }, - { - scope: [ - "constant.numeric", - "constant.language.boolean", - "constant.language.null", - ], - settings: { foreground: "#6f5738" }, - }, - { - scope: ["entity.name.function", "support.function", "variable.function"], - settings: { foreground: "#386f8f" }, - }, - { - scope: [ - "entity.name.type", - "entity.name.class", - "support.type", - "support.class", - ], - settings: { foreground: "#6f5738" }, - }, - { - scope: ["entity.name.tag", "support.type.property-name"], - settings: { foreground: "#8a6f4d" }, - }, - { - scope: [ - "entity.other.attribute-name", - "variable.other.property", - "variable", - ], - settings: { foreground: "#4e463b" }, - }, - { - scope: ["regexp", "constant.character.escape"], - settings: { foreground: "#2f6f73" }, - }, - { - scope: ["invalid", "invalid.illegal"], - settings: { foreground: "#c04b3b", fontStyle: "underline" }, - }, - ], -}; +export function diffThemeNameFromId(id: string): string { + return `shellular-${toThemeId(id)}`; +} + +export function createDiffTheme(theme: Theme) { + const syntax = theme.syntaxTheme; + return { + name: diffThemeName(theme), + type: theme.type === "light" ? ("light" as const) : ("dark" as const), + colors: { + "editor.background": theme.primary, + "editor.foreground": theme.primaryText, + "editor.selectionBackground": withColorAlpha(theme.accent, 0.24), + "editor.lineHighlightBackground": theme.surfaceSoft, + "editorLineNumber.foreground": theme.textMuted, + "editorCursor.foreground": theme.accent, + }, + tokenColors: [ + { + scope: ["comment", "punctuation.definition.comment"], + settings: { foreground: syntax.comment, fontStyle: "italic" }, + }, + { + scope: ["keyword", "storage", "storage.type", "constant.language"], + settings: { foreground: syntax.keyword }, + }, + { + scope: [ + "keyword.operator", + "punctuation.separator", + "punctuation.terminator", + ], + settings: { foreground: syntax.operator }, + }, + { + scope: ["string", "string.quoted", "constant.character"], + settings: { foreground: syntax.string }, + }, + { + scope: [ + "constant.numeric", + "constant.language.boolean", + "constant.language.null", + ], + settings: { foreground: syntax.number }, + }, + { + scope: [ + "entity.name.function", + "support.function", + "variable.function", + ], + settings: { foreground: syntax.function }, + }, + { + scope: [ + "entity.name.type", + "entity.name.class", + "support.type", + "support.class", + ], + settings: { foreground: syntax.type }, + }, + { + scope: ["entity.name.tag", "support.type.property-name"], + settings: { foreground: syntax.tag }, + }, + { + scope: ["entity.other.attribute-name", "variable.other.property"], + settings: { foreground: syntax.property }, + }, + { + scope: ["variable", "identifier"], + settings: { foreground: syntax.variable }, + }, + { + scope: ["regexp", "constant.character.escape"], + settings: { foreground: syntax.regexp }, + }, + { + scope: ["markup.heading", "markup.bold"], + settings: { foreground: syntax.keyword, fontStyle: "bold" }, + }, + { + scope: ["markup.italic", "markup.quote"], + settings: { foreground: syntax.variable, fontStyle: "italic" }, + }, + { + scope: ["invalid", "invalid.illegal"], + settings: { foreground: theme.danger, fontStyle: "underline" }, + }, + ], + }; +} export function registerShellularDiffThemes() { if (registered) return; registered = true; - registerCustomTheme("shellular-champagne-noir", () => - Promise.resolve(shellularDarkTheme), - ); - registerCustomTheme("shellular-champagne-light", () => - Promise.resolve(shellularLightTheme), - ); + + for (const option of themes.options) { + registerCustomTheme(diffThemeNameFromId(option.id), async () => + createDiffTheme(await themes.get(option.id)), + ); + } } diff --git a/src/lib/e2ee.test.ts b/src/lib/e2ee.test.ts new file mode 100644 index 0000000..b5c8036 --- /dev/null +++ b/src/lib/e2ee.test.ts @@ -0,0 +1,89 @@ +import sodium from "libsodium-wrappers"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + decryptMessage, + decryptProxyBinaryFrame, + encryptMessage, + encryptTcpTunnelDataFrame, + initSodium, + TCP_TUNNEL_MAX_FRAME_BYTES, +} from "./e2ee"; + +describe("encrypted message identity", () => { + beforeAll(async () => { + await initSodium(); + }); + + it("copies the authenticated client ID into the outer envelope and ciphertext", () => { + const key = sodium.crypto_secretbox_keygen(); + const envelope = encryptMessage( + { + id: "request-1", + type: "fs:list", + clientId: "c_local-test", + }, + key, + ); + + expect(envelope.clientId).toBe("c_local-test"); + expect(decryptMessage(envelope, key)).toMatchObject({ + id: "request-1", + type: "fs:list", + clientId: "c_local-test", + }); + }); +}); + +describe("encrypted TCP tunnel frames", () => { + beforeAll(async () => { + await initSodium(); + }); + + it("round-trips the routing identity, sequence, and opaque bytes", () => { + const key = sodium.crypto_secretbox_keygen(); + const payload = new TextEncoder().encode("ordered tunnel bytes"); + const frame = encryptTcpTunnelDataFrame( + "client-123", + "tunnel-123", + 42, + payload, + key, + ); + + const decoded = decryptProxyBinaryFrame( + Uint8Array.from(frame).buffer, + key, + ); + expect(decoded).toMatchObject({ + clientId: "client-123", + tunnelId: "tunnel-123", + sequence: 42, + }); + expect(Array.from(decoded?.data ?? [])).toEqual(Array.from(payload)); + }); + + it("rejects oversized data and tampered routing headers", () => { + const key = sodium.crypto_secretbox_keygen(); + expect(() => + encryptTcpTunnelDataFrame( + "client-123", + "tunnel-123", + 0, + new Uint8Array(TCP_TUNNEL_MAX_FRAME_BYTES + 1), + key, + ), + ).toThrow("too large"); + + const frame = encryptTcpTunnelDataFrame( + "client-123", + "tunnel-123", + 0, + new Uint8Array([1, 2, 3]), + key, + ); + frame[31] ^= 1; + expect( + decryptProxyBinaryFrame(Uint8Array.from(frame).buffer, key), + ).toBeNull(); + }); +}); diff --git a/src/lib/e2ee.ts b/src/lib/e2ee.ts index 1cc306a..a39052f 100644 --- a/src/lib/e2ee.ts +++ b/src/lib/e2ee.ts @@ -4,7 +4,9 @@ let _ready = false; const PROXY_BINARY_MAGIC = new Uint8Array([0x53, 0x48, 0x50, 0x42]); const PROXY_BINARY_VERSION = 1; const PROXY_BINARY_KIND_HTTP_RESPONSE_DATA = 1; +const PROXY_BINARY_KIND_TCP_TUNNEL_DATA = 2; const PROXY_BINARY_HEADER_BYTES = 4 + 1 + 1 + 1 + 24; +export const TCP_TUNNEL_MAX_FRAME_BYTES = 64 * 1024; const textDecoder = new TextDecoder(); export async function initSodium(): Promise { @@ -58,6 +60,7 @@ export function isPlaintextMessage(type: string): boolean { export interface EncryptedEnvelope { id: string; type: "encrypted"; + clientId: string; nonce: string; ciphertext: string; } @@ -70,12 +73,24 @@ export interface ProxyBinaryHttpResponseData { data: Uint8Array; } +export interface ProxyBinaryTcpTunnelData { + kind: typeof PROXY_BINARY_KIND_TCP_TUNNEL_DATA; + clientId: string; + tunnelId: string; + sequence: number; + data: Uint8Array; +} + +export type ProxyBinaryData = + | ProxyBinaryHttpResponseData + | ProxyBinaryTcpTunnelData; + /** * Encrypt a fully-formed message object (already has `id`). * Returns the encrypted envelope to send on the wire. */ export function encryptMessage( - msg: { id: string; type: string }, + msg: { id: string; type: string; clientId: string }, key: Uint8Array, ): EncryptedEnvelope { const plaintext = JSON.stringify(msg); @@ -85,6 +100,7 @@ export function encryptMessage( return { id: msg.id, type: "encrypted", + clientId: msg.clientId, nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL), ciphertext: sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL), }; @@ -118,7 +134,7 @@ export function decryptMessage( export function decryptProxyBinaryFrame( frame: ArrayBuffer, key: Uint8Array, -): ProxyBinaryHttpResponseData | null { +): ProxyBinaryData | null { try { const bytes = new Uint8Array(frame); if (bytes.length < PROXY_BINARY_HEADER_BYTES) return null; @@ -132,7 +148,8 @@ export function decryptProxyBinaryFrame( const clientIdLength = bytes[6]; if ( version !== PROXY_BINARY_VERSION || - kind !== PROXY_BINARY_KIND_HTTP_RESPONSE_DATA || + (kind !== PROXY_BINARY_KIND_HTTP_RESPONSE_DATA && + kind !== PROXY_BINARY_KIND_TCP_TUNNEL_DATA) || clientIdLength === 0 ) { return null; @@ -177,12 +194,31 @@ export function decryptProxyBinaryFrame( ); if (plaintextClientId !== clientId) return null; + const identifier = textDecoder.decode( + plaintext.subarray(requestIdStart, dataStart), + ); + if (kind === PROXY_BINARY_KIND_TCP_TUNNEL_DATA) { + const data = plaintext.subarray(dataStart); + if ( + identifier.length < 8 || + identifier.length > 96 || + data.byteLength === 0 || + data.byteLength > TCP_TUNNEL_MAX_FRAME_BYTES + ) { + return null; + } + return { + kind, + clientId, + tunnelId: identifier, + sequence: chunkIndex, + data, + }; + } return { - kind: PROXY_BINARY_KIND_HTTP_RESPONSE_DATA, + kind, clientId, - requestId: textDecoder.decode( - plaintext.subarray(requestIdStart, dataStart), - ), + requestId: identifier, chunkIndex, data: plaintext.subarray(dataStart), }; @@ -192,6 +228,51 @@ export function decryptProxyBinaryFrame( } } +export function encryptTcpTunnelDataFrame( + clientId: string, + tunnelId: string, + sequence: number, + data: Uint8Array, + key: Uint8Array, +): Uint8Array { + if (data.byteLength > TCP_TUNNEL_MAX_FRAME_BYTES) { + throw new Error("TCP tunnel frame is too large"); + } + const clientIdBytes = new TextEncoder().encode(clientId); + const tunnelIdBytes = new TextEncoder().encode(tunnelId); + if (clientIdBytes.length === 0 || clientIdBytes.length > 255) { + throw new Error("Invalid client ID"); + } + if (tunnelIdBytes.length === 0 || tunnelIdBytes.length > 65_535) { + throw new Error("Invalid tunnel ID"); + } + const plaintext = new Uint8Array( + 8 + clientIdBytes.length + tunnelIdBytes.length + data.length, + ); + const plaintextView = new DataView(plaintext.buffer); + plaintextView.setUint8(0, PROXY_BINARY_KIND_TCP_TUNNEL_DATA); + plaintextView.setUint8(1, clientIdBytes.length); + plaintextView.setUint16(2, tunnelIdBytes.length, false); + plaintextView.setUint32(4, sequence, false); + plaintext.set(clientIdBytes, 8); + plaintext.set(tunnelIdBytes, 8 + clientIdBytes.length); + plaintext.set(data, 8 + clientIdBytes.length + tunnelIdBytes.length); + + const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key); + const frame = new Uint8Array( + PROXY_BINARY_HEADER_BYTES + clientIdBytes.length + ciphertext.length, + ); + frame.set(PROXY_BINARY_MAGIC, 0); + frame[4] = PROXY_BINARY_VERSION; + frame[5] = PROXY_BINARY_KIND_TCP_TUNNEL_DATA; + frame[6] = clientIdBytes.length; + frame.set(nonce, 7); + frame.set(clientIdBytes, PROXY_BINARY_HEADER_BYTES); + frame.set(ciphertext, PROXY_BINARY_HEADER_BYTES + clientIdBytes.length); + return frame; +} + /** * Encode a raw Uint8Array key to standard base64 for persistence. */ diff --git a/src/lib/elementResize.test.ts b/src/lib/elementResize.test.ts new file mode 100644 index 0000000..7a75900 --- /dev/null +++ b/src/lib/elementResize.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { observeElementResize } from "./elementResize"; + +class TestResizeObserver { + static instance: TestResizeObserver | undefined; + private readonly callback: ResizeObserverCallback; + disconnect = vi.fn(); + observe = vi.fn(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + TestResizeObserver.instance = this; + } + + emit(target: Element, width: number, height: number) { + this.callback( + [ + { + target, + contentRect: { width, height }, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ); + } +} + +describe("observeElementResize", () => { + let frames: FrameRequestCallback[]; + + beforeEach(() => { + vi.useFakeTimers(); + frames = []; + vi.stubGlobal("ResizeObserver", TestResizeObserver); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + TestResizeObserver.instance = undefined; + }); + + it("delivers a positive initial size immediately", () => { + const element = document.createElement("div"); + vi.spyOn(element, "getBoundingClientRect").mockReturnValue({ + width: 640.8, + height: 480.4, + } as DOMRect); + const onFrame = vi.fn(); + + observeElementResize(element, { onFrame }); + + expect(onFrame).toHaveBeenCalledWith({ width: 640, height: 480 }); + }); + + it("coalesces a resize burst into the latest frame", () => { + const element = document.createElement("div"); + const onFrame = vi.fn(); + observeElementResize(element, { onFrame }); + TestResizeObserver.instance?.emit(element, 800, 600); + TestResizeObserver.instance?.emit(element, 900, 700); + + expect(frames).toHaveLength(1); + frames.shift()?.(0); + expect(onFrame).toHaveBeenCalledTimes(1); + expect(onFrame).toHaveBeenCalledWith({ width: 900, height: 700 }); + }); + + it("delivers pre-paint sizes without adding an animation-frame delay", () => { + const element = document.createElement("div"); + const onFrame = vi.fn(); + observeElementResize(element, { onFrame, delivery: "pre-paint" }); + + TestResizeObserver.instance?.emit(element, 800, 600); + + expect(onFrame).toHaveBeenCalledWith({ width: 800, height: 600 }); + expect(frames).toHaveLength(0); + }); + + it("ignores zero and duplicate sizes", () => { + const element = document.createElement("div"); + const onFrame = vi.fn(); + observeElementResize(element, { onFrame }); + TestResizeObserver.instance?.emit(element, 0, 700); + TestResizeObserver.instance?.emit(element, 800, 600); + frames.shift()?.(0); + TestResizeObserver.instance?.emit(element, 800, 600); + + expect(onFrame).toHaveBeenCalledTimes(1); + }); + + it("lays out a hidden element when its tab becomes visible", () => { + const element = document.createElement("div"); + const onFrame = vi.fn(); + observeElementResize(element, { onFrame }); + + TestResizeObserver.instance?.emit(element, 0, 0); + TestResizeObserver.instance?.emit(element, 720, 540); + frames.shift()?.(0); + + expect(onFrame).toHaveBeenCalledOnce(); + expect(onFrame).toHaveBeenCalledWith({ width: 720, height: 540 }); + }); + + it("flushes the final size and cleans up pending work", () => { + const element = document.createElement("div"); + const onFrame = vi.fn(); + const onSettled = vi.fn(); + const dispose = observeElementResize(element, { + onFrame, + onSettled, + settleDelay: 50, + }); + TestResizeObserver.instance?.emit(element, 1024, 768); + + vi.advanceTimersByTime(50); + expect(onFrame).toHaveBeenCalledWith({ width: 1024, height: 768 }); + expect(onSettled).toHaveBeenCalledWith({ width: 1024, height: 768 }); + + dispose(); + expect(TestResizeObserver.instance?.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/elementResize.ts b/src/lib/elementResize.ts new file mode 100644 index 0000000..f4bad7a --- /dev/null +++ b/src/lib/elementResize.ts @@ -0,0 +1,93 @@ +export interface ElementSize { + width: number; + height: number; +} + +export interface ElementResizeHandlers { + onFrame: (size: ElementSize) => void; + onSettled?: (size: ElementSize) => void; + settleDelay?: number; + delivery?: "animation-frame" | "pre-paint"; +} + +const DEFAULT_SETTLE_DELAY = 100; + +function normalizeSize(width: number, height: number): ElementSize | null { + const normalized = { + width: Math.floor(width), + height: Math.floor(height), + }; + return normalized.width > 0 && normalized.height > 0 ? normalized : null; +} + +function sameSize(left: ElementSize | null, right: ElementSize | null) { + return left?.width === right?.width && left?.height === right?.height; +} + +/** + * Observe an element without letting resize bursts schedule more than one + * expensive layout per display frame. A final callback is emitted once the + * element has remained stable long enough for terminal/editor finalization. + */ +export function observeElementResize( + element: Element, + handlers: ElementResizeHandlers, +) { + let disposed = false; + let frame = 0; + let settleTimer: ReturnType | undefined; + let pending: ElementSize | null = null; + let delivered: ElementSize | null = null; + + const deliver = () => { + frame = 0; + if (disposed || !pending || sameSize(pending, delivered)) return; + delivered = pending; + handlers.onFrame(delivered); + }; + + const settle = () => { + settleTimer = undefined; + if (disposed || !pending) return; + if (frame) { + cancelAnimationFrame(frame); + frame = 0; + } + deliver(); + if (delivered) handlers.onSettled?.(delivered); + }; + + const schedule = (size: ElementSize | null, immediate = false) => { + if (disposed || !size || sameSize(size, pending)) return; + pending = size; + if (settleTimer) clearTimeout(settleTimer); + settleTimer = setTimeout( + settle, + handlers.settleDelay ?? DEFAULT_SETTLE_DELAY, + ); + if (immediate) { + deliver(); + } else if (handlers.delivery === "pre-paint") { + deliver(); + } else if (!frame) { + frame = requestAnimationFrame(deliver); + } + }; + + const rect = element.getBoundingClientRect(); + schedule(normalizeSize(rect.width, rect.height), true); + + const observer = new ResizeObserver((entries) => { + const entry = entries.find((candidate) => candidate.target === element); + if (!entry) return; + schedule(normalizeSize(entry.contentRect.width, entry.contentRect.height)); + }); + observer.observe(element); + + return () => { + disposed = true; + observer.disconnect(); + if (frame) cancelAnimationFrame(frame); + if (settleTimer) clearTimeout(settleTimer); + }; +} diff --git a/src/lib/horizontalWheel.test.ts b/src/lib/horizontalWheel.test.ts new file mode 100644 index 0000000..545970b --- /dev/null +++ b/src/lib/horizontalWheel.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { redirectVerticalWheelToHorizontal } from "./horizontalWheel"; + +function wheel( + overrides: Partial< + Parameters[0] + > = {}, +) { + return { + deltaX: 0, + deltaY: 20, + preventDefault: vi.fn(), + currentTarget: { scrollLeft: 10, scrollWidth: 200, clientWidth: 100 }, + ...overrides, + }; +} + +describe("redirectVerticalWheelToHorizontal", () => { + it("uses an ordinary vertical wheel while horizontal overflow remains", () => { + const event = wheel(); + expect(redirectVerticalWheelToHorizontal(event)).toBe(true); + expect(event.currentTarget.scrollLeft).toBe(30); + expect(event.preventDefault).toHaveBeenCalledOnce(); + }); + + it("does not consume native horizontal gestures or boundary movement", () => { + const gesture = wheel({ deltaX: 30, deltaY: 10 }); + expect(redirectVerticalWheelToHorizontal(gesture)).toBe(false); + expect(gesture.preventDefault).not.toHaveBeenCalled(); + + const boundary = wheel({ + deltaY: -20, + currentTarget: { scrollLeft: 0, scrollWidth: 200, clientWidth: 100 }, + }); + expect(redirectVerticalWheelToHorizontal(boundary)).toBe(false); + expect(boundary.preventDefault).not.toHaveBeenCalled(); + }); + + it("does not consume wheel events when there is no overflow", () => { + const event = wheel({ + currentTarget: { scrollLeft: 0, scrollWidth: 100, clientWidth: 100 }, + }); + expect(redirectVerticalWheelToHorizontal(event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/horizontalWheel.ts b/src/lib/horizontalWheel.ts new file mode 100644 index 0000000..845ef47 --- /dev/null +++ b/src/lib/horizontalWheel.ts @@ -0,0 +1,24 @@ +export interface HorizontalWheelEvent { + deltaX: number; + deltaY: number; + preventDefault(): void; + currentTarget: { + scrollLeft: number; + scrollWidth: number; + clientWidth: number; + }; +} + +export function redirectVerticalWheelToHorizontal( + event: HorizontalWheelEvent, +): boolean { + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return false; + const target = event.currentTarget; + const maximum = target.scrollWidth - target.clientWidth; + if (maximum <= 0) return false; + const next = Math.min(maximum, Math.max(0, target.scrollLeft + event.deltaY)); + if (next === target.scrollLeft) return false; + target.scrollLeft = next; + event.preventDefault(); + return true; +} diff --git a/src/lib/keybindings.test.ts b/src/lib/keybindings.test.ts new file mode 100644 index 0000000..5ede0e2 --- /dev/null +++ b/src/lib/keybindings.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const storage = vi.hoisted(() => ({ + exists: false, + text: "", + writes: [] as string[], +})); + +vi.mock("bridge/file", () => ({ + default: { + exists: vi.fn(async () => storage.exists), + read: vi.fn(async () => storage.text), + write: vi.fn(async (_path: string, text: string) => { + storage.writes.push(text); + }), + }, +})); + +import { + getKeybindingsSnapshot, + initializeKeybindings, + resetCommandKeybindings, + resetKeybindingsForTests, + resolvedKeybindings, + setCommandKeybindings, +} from "./keybindings"; + +describe("keybinding persistence", () => { + beforeEach(() => { + storage.exists = false; + storage.text = ""; + storage.writes = []; + resetKeybindingsForTests(); + }); + + it("falls back to defaults when no file exists", async () => { + await initializeKeybindings(); + expect(getKeybindingsSnapshot().initialized).toBe(true); + expect(resolvedKeybindings("linux")["toggle-terminal"]).toEqual([ + { + strokes: [{ key: "`", modifiers: ["ctrl"] }], + }, + ]); + }); + + it("normalizes valid platform overrides and ignores malformed entries", async () => { + storage.exists = true; + storage.text = JSON.stringify({ + version: 1, + overrides: { + mac: { + "toggle-terminal": [ + { + strokes: [{ key: "T", modifiers: ["meta", "shift", "bogus"] }], + }, + ], + unknown: [{ strokes: [{ key: "q" }] }], + settings: [{ strokes: [] }], + }, + }, + }); + await initializeKeybindings(); + expect(resolvedKeybindings("mac")["toggle-terminal"]).toEqual([ + { + strokes: [{ key: "t", modifiers: ["shift", "meta"] }], + }, + ]); + expect(resolvedKeybindings("mac").settings).toEqual([ + { strokes: [{ key: ",", modifiers: ["meta"] }] }, + ]); + }); + + it("persists explicit unbinding and reset restores the default", async () => { + await initializeKeybindings(); + await setCommandKeybindings("windows", "settings", []); + expect(resolvedKeybindings("windows").settings).toEqual([]); + expect( + JSON.parse(storage.writes[storage.writes.length - 1] ?? "{}"), + ).toMatchObject({ + version: 1, + overrides: { windows: { settings: [] } }, + }); + + await resetCommandKeybindings("windows", "settings"); + expect(resolvedKeybindings("windows").settings).toEqual([ + { strokes: [{ key: ",", modifiers: ["ctrl"] }] }, + ]); + }); +}); diff --git a/src/lib/keybindings.ts b/src/lib/keybindings.ts new file mode 100644 index 0000000..2b5ef65 --- /dev/null +++ b/src/lib/keybindings.ts @@ -0,0 +1,254 @@ +import file from "bridge/file"; +import appConfig from "lib/appConfig"; +import { + DESKTOP_COMMAND_IDS, + type DesktopKeyboardCommand, + type DesktopShortcutPlatform, + type KeybindingOverrides, + normalizeShortcutBinding, + type ResolvedKeybindings, + resolveKeybindings, + type ShortcutBinding, +} from "workbench/desktopShortcuts"; + +const KEYBINDINGS_PATH = `${appConfig.DATA_DIR}/keybindings.json`; +const KEYBINDINGS_VERSION = 1; + +export interface KeybindingsFile { + version: 1; + overrides: KeybindingOverrides; +} + +export interface KeybindingsSnapshot { + initialized: boolean; + revision: number; + overrides: KeybindingOverrides; +} + +const EMPTY_SNAPSHOT: KeybindingsSnapshot = { + initialized: false, + revision: 0, + overrides: {}, +}; + +let snapshot = EMPTY_SNAPSHOT; +let initialization: Promise | null = null; +let writeTail: Promise = Promise.resolve(); +const listeners = new Set<() => void>(); + +export function getKeybindingsSnapshot() { + return snapshot; +} + +export function subscribeKeybindings(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export async function initializeKeybindings() { + if (snapshot.initialized) return snapshot; + if (initialization) return initialization; + initialization = readKeybindings() + .then((overrides) => { + emit(overrides); + return snapshot; + }) + .finally(() => { + initialization = null; + }); + return initialization; +} + +export function resolvedKeybindings( + platform: DesktopShortcutPlatform, +): ResolvedKeybindings { + return resolveKeybindings(platform, snapshot.overrides); +} + +export async function setCommandKeybindings( + platform: DesktopShortcutPlatform, + command: DesktopKeyboardCommand, + bindings: ShortcutBinding[], +) { + return updateKeybindings((overrides) => ({ + ...overrides, + [platform]: { + ...overrides[platform], + [command]: normalizeBindings(bindings), + }, + })); +} + +export async function updateCommandKeybindings( + platform: DesktopShortcutPlatform, + updates: Partial>, +) { + return updateKeybindings((overrides) => ({ + ...overrides, + [platform]: { + ...overrides[platform], + ...Object.fromEntries( + Object.entries(updates).map(([command, bindings]) => [ + command, + normalizeBindings(bindings ?? []), + ]), + ), + }, + })); +} + +export async function applyKeybindingChanges( + platform: DesktopShortcutPlatform, + changes: { + set?: Partial>; + reset?: DesktopKeyboardCommand[]; + }, +) { + return updateKeybindings((overrides) => { + const platformOverrides = { ...overrides[platform] }; + for (const command of changes.reset ?? []) + delete platformOverrides[command]; + for (const [command, bindings] of Object.entries(changes.set ?? {})) { + platformOverrides[command as DesktopKeyboardCommand] = normalizeBindings( + bindings ?? [], + ); + } + return { ...overrides, [platform]: platformOverrides }; + }); +} + +export async function resetCommandKeybindings( + platform: DesktopShortcutPlatform, + command: DesktopKeyboardCommand, +) { + return updateKeybindings((overrides) => { + const platformOverrides = { ...overrides[platform] }; + delete platformOverrides[command]; + return { + ...overrides, + [platform]: platformOverrides, + }; + }); +} + +export async function resetPlatformKeybindings( + platform: DesktopShortcutPlatform, +) { + return updateKeybindings((overrides) => ({ + ...overrides, + [platform]: {}, + })); +} + +async function updateKeybindings( + update: (overrides: KeybindingOverrides) => KeybindingOverrides, +) { + await initializeKeybindings(); + const task = writeTail.then(async () => { + const next = normalizeOverrides(update(snapshot.overrides)); + const payload: KeybindingsFile = { + version: KEYBINDINGS_VERSION, + overrides: next, + }; + await file.write(KEYBINDINGS_PATH, JSON.stringify(payload)); + emit(next); + }); + writeTail = task.catch(() => undefined); + return task; +} + +async function readKeybindings(): Promise { + try { + if (!(await file.exists(KEYBINDINGS_PATH))) return {}; + const text = (await file.read(KEYBINDINGS_PATH, "text")) as string; + const parsed = JSON.parse(text) as Partial; + if (parsed.version !== KEYBINDINGS_VERSION) return {}; + return normalizeOverrides(parsed.overrides); + } catch { + return {}; + } +} + +function normalizeOverrides(value: unknown): KeybindingOverrides { + if (!value || typeof value !== "object") return {}; + const input = value as Record; + const result: KeybindingOverrides = {}; + for (const platform of ["mac", "windows", "linux"] as const) { + const rawPlatform = input[platform]; + if (!rawPlatform || typeof rawPlatform !== "object") continue; + const rawCommands = rawPlatform as Record; + const normalized: Partial< + Record + > = {}; + for (const command of DESKTOP_COMMAND_IDS) { + if (!(command in rawCommands)) continue; + const rawBindings = rawCommands[command]; + if (!Array.isArray(rawBindings)) continue; + const bindings = normalizeBindings(rawBindings); + if (rawBindings.length > 0 && bindings.length === 0) continue; + normalized[command] = bindings; + } + result[platform] = normalized; + } + return result; +} + +function normalizeBindings(values: unknown[]): ShortcutBinding[] { + const result: ShortcutBinding[] = []; + for (const value of values) { + if (!value || typeof value !== "object") continue; + const strokes = (value as { strokes?: unknown }).strokes; + if (!Array.isArray(strokes) || strokes.length < 1 || strokes.length > 2) + continue; + const normalized = strokes.map((stroke) => normalizeStroke(stroke)); + if (normalized.some((stroke) => stroke === null)) continue; + result.push( + normalizeShortcutBinding({ + strokes: normalized as ShortcutBinding["strokes"], + }), + ); + } + return result; +} + +function normalizeStroke(value: unknown) { + if (!value || typeof value !== "object") return null; + const stroke = value as { key?: unknown; modifiers?: unknown }; + if ( + typeof stroke.key !== "string" || + (!stroke.key.trim() && stroke.key !== " ") + ) + return null; + const modifiers = Array.isArray(stroke.modifiers) + ? stroke.modifiers.filter( + (value): value is "ctrl" | "alt" | "shift" | "meta" => + value === "ctrl" || + value === "alt" || + value === "shift" || + value === "meta", + ) + : []; + return { + key: stroke.key === " " ? stroke.key : stroke.key.trim(), + modifiers: [...new Set(modifiers)], + }; +} + +function emit(overrides: KeybindingOverrides) { + snapshot = { + initialized: true, + revision: snapshot.revision + 1, + overrides, + }; + for (const listener of listeners) listener(); + window.dispatchEvent( + new CustomEvent("shellular:keybindings-changed", { detail: snapshot }), + ); +} + +export function resetKeybindingsForTests() { + snapshot = EMPTY_SNAPSHOT; + initialization = null; + writeTail = Promise.resolve(); + listeners.clear(); +} diff --git a/src/lib/monacoClipboard.test.ts b/src/lib/monacoClipboard.test.ts new file mode 100644 index 0000000..7cd1f2b --- /dev/null +++ b/src/lib/monacoClipboard.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { installMonacoClipboardAdapter } from "./monacoClipboard"; + +function setup() { + const target = {} as Navigator & { clipboard?: Clipboard }; + const scope = { Blob } as { + Blob: typeof Blob; + ClipboardItem?: typeof ClipboardItem; + }; + const bridge = { + readClipboardText: vi.fn(async () => "from pasteboard"), + writeClipboardText: vi.fn(async () => undefined), + }; + expect(installMonacoClipboardAdapter(target, bridge, scope)).toBe(true); + return { + bridge, + clipboard: target.clipboard as Clipboard, + ClipboardItem: scope.ClipboardItem as typeof ClipboardItem, + }; +} + +describe("macOS Monaco clipboard adapter", () => { + it("routes text reads and writes through the native pasteboard", async () => { + const { bridge, clipboard } = setup(); + await expect(clipboard.readText()).resolves.toBe("from pasteboard"); + await clipboard.writeText("copied"); + expect(bridge.writeClipboardText).toHaveBeenCalledWith("copied"); + }); + + it("resolves Monaco ClipboardItem writes", async () => { + const { bridge, clipboard, ClipboardItem } = setup(); + await clipboard.write([ + new ClipboardItem({ "text/plain": Promise.resolve("item text") }), + ]); + expect(bridge.writeClipboardText).toHaveBeenCalledWith("item text"); + }); + + it("absorbs expected canceled deferred writes", async () => { + const { bridge, clipboard, ClipboardItem } = setup(); + await expect( + clipboard.write([ + new ClipboardItem({ + "text/plain": Promise.reject(new Error("Canceled")), + }), + ]), + ).resolves.toBeUndefined(); + expect(bridge.writeClipboardText).not.toHaveBeenCalled(); + }); + + it("installs the ClipboardItem global Monaco's WebKit workaround requires", () => { + const { ClipboardItem } = setup(); + const item = new ClipboardItem({ "text/plain": "copied" }); + expect(item.types).toEqual(["text/plain"]); + expect(ClipboardItem.supports("text/plain")).toBe(true); + }); +}); diff --git a/src/lib/monacoClipboard.ts b/src/lib/monacoClipboard.ts new file mode 100644 index 0000000..167235d --- /dev/null +++ b/src/lib/monacoClipboard.ts @@ -0,0 +1,123 @@ +import native from "bridge/native"; + +interface ClipboardBridge { + readClipboardText(): Promise; + writeClipboardText(text: string): Promise; +} + +type ClipboardNavigator = Navigator & { clipboard?: Clipboard }; + +type ClipboardItemValue = string | Blob | PromiseLike; +type ClipboardScope = { + Blob: typeof Blob; + ClipboardItem?: typeof ClipboardItem; +}; + +function ensureClipboardItem(scope: ClipboardScope) { + if (typeof scope.ClipboardItem === "function") return scope.ClipboardItem; + + class ClipboardItemPolyfill { + readonly presentationStyle = "unspecified"; + readonly types: string[]; + + constructor( + private readonly items: Record, + _options?: ClipboardItemOptions, + ) { + this.types = Object.keys(items); + } + + async getType(type: string) { + if (!Object.prototype.hasOwnProperty.call(this.items, type)) { + throw new DOMException( + `Clipboard type ${type} is unavailable`, + "NotFoundError", + ); + } + const value = await this.items[type]; + return value instanceof scope.Blob + ? value + : new scope.Blob([value], { type }); + } + + static supports(type: string) { + return type === "text/plain"; + } + } + + try { + Object.defineProperty(scope, "ClipboardItem", { + configurable: true, + value: ClipboardItemPolyfill, + }); + return ClipboardItemPolyfill as unknown as typeof ClipboardItem; + } catch { + return null; + } +} + +async function clipboardItemText(item: ClipboardItem) { + if (!item.types.includes("text/plain")) return null; + const value = await item.getType("text/plain"); + return value.text(); +} + +export function installMonacoClipboardAdapter( + target: ClipboardNavigator = navigator, + bridge: ClipboardBridge = native, + scope: ClipboardScope = globalThis, +) { + const ClipboardItemConstructor = ensureClipboardItem(scope); + const current = target.clipboard; + if ( + current && + typeof current.write === "function" && + typeof current.writeText === "function" && + typeof current.readText === "function" + ) { + return false; + } + + const adapter = { + async readText() { + return bridge.readClipboardText(); + }, + async writeText(text: string) { + await bridge.writeClipboardText(text); + }, + async read() { + const text = await bridge.readClipboardText(); + if (!ClipboardItemConstructor) return []; + return [ + new ClipboardItemConstructor({ + "text/plain": new Blob([text], { type: "text/plain" }), + }), + ]; + }, + async write(items: ClipboardItem[]) { + for (const item of items) { + try { + const text = await clipboardItemText(item); + if (text !== null) { + await bridge.writeClipboardText(text); + return; + } + } catch { + // Monaco intentionally cancels a pending WebKit clipboard item on + // the next gesture. Treat that internal cancellation as completion. + return; + } + } + }, + } as Clipboard; + + try { + Object.defineProperty(target, "clipboard", { + configurable: true, + value: adapter, + }); + return true; + } catch { + return false; + } +} diff --git a/src/lib/monacoColor.test.ts b/src/lib/monacoColor.test.ts new file mode 100644 index 0000000..fa1c421 --- /dev/null +++ b/src/lib/monacoColor.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + monacoTokenColor, + normalizeMonacoColor, + withMonacoAlpha, +} from "./monacoColor"; + +describe("Monaco color normalization", () => { + it.each([ + ["#abc", "#AABBCC"], + ["#abcd", "#AABBCCDD"], + ["#123456", "#123456"], + ["#12345678", "#12345678"], + ["rgb(60, 60, 67)", "#3C3C43"], + ["rgba(222, 214, 200, 0.66)", "#DED6C8A8"], + ["transparent", "#00000000"], + ])("converts %s to Monaco hex", (input, expected) => { + expect(normalizeMonacoColor(input)).toBe(expected); + }); + + it("uses a valid fallback instead of Monaco's red invalid-color sentinel", () => { + expect( + normalizeMonacoColor("color-mix(in srgb, red, blue)", "#102030"), + ).toBe("#102030"); + expect(normalizeMonacoColor("invalid", "also invalid")).toBe("#000000"); + }); + + it("can apply alpha and produce token colors without a hash", () => { + expect(withMonacoAlpha("#5856D6", 0.2)).toBe("#5856D633"); + expect(monacoTokenColor("rgba(145, 197, 138, 0.5)")).toBe("91C58A"); + }); +}); diff --git a/src/lib/monacoColor.ts b/src/lib/monacoColor.ts new file mode 100644 index 0000000..cacd037 --- /dev/null +++ b/src/lib/monacoColor.ts @@ -0,0 +1,55 @@ +const HEX_COLOR = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i; +const RGB_COLOR = + /^rgba?\(\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+))(?:\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+)))?\s*\)$/i; + +function hexByte(value: number) { + return Math.round(Math.min(255, Math.max(0, value))) + .toString(16) + .padStart(2, "0") + .toUpperCase(); +} + +function parseMonacoColor(value: string): string | null { + const input = value.trim(); + if (input.toLowerCase() === "transparent") return "#00000000"; + + const hex = input.match(HEX_COLOR)?.[1]; + if (hex) { + const expanded = + hex.length <= 4 + ? [...hex].map((character) => `${character}${character}`).join("") + : hex; + return `#${expanded.toUpperCase()}`; + } + + const rgb = input.match(RGB_COLOR); + if (!rgb) return null; + const color = `#${hexByte(Number(rgb[1]))}${hexByte(Number(rgb[2]))}${hexByte(Number(rgb[3]))}`; + if (rgb[4] === undefined) return color; + return `${color}${hexByte(Number(rgb[4]) * 255)}`; +} + +export function normalizeMonacoColor( + value: string | undefined, + fallback = "#000000", +) { + return ( + (value ? parseMonacoColor(value) : null) ?? + parseMonacoColor(fallback) ?? + "#000000" + ); +} + +export function withColorAlpha( + value: string | undefined, + alpha: number, + fallback = "#000000", +) { + return `${normalizeMonacoColor(value, fallback).slice(0, 7)}${hexByte(alpha * 255)}`; +} + +export const withMonacoAlpha = withColorAlpha; + +export function monacoTokenColor(value: string | undefined, fallback?: string) { + return normalizeMonacoColor(value, fallback).slice(1, 7); +} diff --git a/src/lib/monacoModels.test.ts b/src/lib/monacoModels.test.ts new file mode 100644 index 0000000..676dded --- /dev/null +++ b/src/lib/monacoModels.test.ts @@ -0,0 +1,61 @@ +import type * as Monaco from "monaco-editor"; +import { describe, expect, it, vi } from "vitest"; +import { + acquireMonacoModel, + isMonacoModelDirty, + monacoModelCount, +} from "./monacoModels"; + +function fakeMonaco() { + const model = { + value: "", + disposed: false, + getValue() { + return this.value; + }, + setValue(value: string) { + this.value = value; + }, + isDisposed() { + return this.disposed; + }, + dispose() { + this.disposed = true; + }, + }; + const createModel = vi.fn((value: string) => { + model.value = value; + return model; + }); + return { + monaco: { editor: { createModel } } as unknown as typeof Monaco, + model, + createModel, + }; +} + +describe("Monaco model registry", () => { + it("reuses a URI and disposes it after the final reference", () => { + const { monaco, model, createModel } = fakeMonaco(); + const uri = { + toString: () => "shellular-file://host/project/app.ts", + } as Monaco.Uri; + const first = acquireMonacoModel(monaco, uri, "one", "typescript"); + const second = acquireMonacoModel(monaco, uri, "two", "typescript"); + + expect(createModel).toHaveBeenCalledTimes(1); + expect(first.model).toBe(second.model); + expect(model.value).toBe("two"); + expect(monacoModelCount()).toBe(1); + first.release(); + expect(model.disposed).toBe(false); + second.release(); + expect(model.disposed).toBe(true); + expect(monacoModelCount()).toBe(0); + }); + + it("treats undo back to the saved alternative version as clean", () => { + expect(isMonacoModelDirty(8, 7)).toBe(true); + expect(isMonacoModelDirty(7, 7)).toBe(false); + }); +}); diff --git a/src/lib/monacoModels.ts b/src/lib/monacoModels.ts new file mode 100644 index 0000000..5ce70e1 --- /dev/null +++ b/src/lib/monacoModels.ts @@ -0,0 +1,50 @@ +import type * as Monaco from "monaco-editor"; + +type Entry = { model: Monaco.editor.ITextModel; references: number }; +const models = new Map(); + +export function acquireMonacoModel( + monaco: typeof Monaco, + uri: Monaco.Uri, + value: string, + language: string, +) { + const key = uri.toString(); + let entry = models.get(key); + if (!entry || entry.model.isDisposed()) { + entry = { + model: monaco.editor.createModel(value, language, uri), + references: 0, + }; + models.set(key, entry); + } else if (entry.model.getValue() !== value) { + entry.model.setValue(value); + } + entry.references += 1; + let released = false; + return { + model: entry.model, + release() { + if (released) return; + released = true; + const current = models.get(key); + if (!current) return; + current.references -= 1; + if (current.references <= 0) { + current.model.dispose(); + models.delete(key); + } + }, + }; +} + +export function monacoModelCount() { + return models.size; +} + +export function isMonacoModelDirty( + currentVersion: number, + savedVersion: number, +) { + return currentVersion !== savedVersion; +} diff --git a/src/lib/monacoRuntime.test.ts b/src/lib/monacoRuntime.test.ts new file mode 100644 index 0000000..94505bc --- /dev/null +++ b/src/lib/monacoRuntime.test.ts @@ -0,0 +1,31 @@ +import type * as Monaco from "monaco-editor"; +import { describe, expect, it } from "vitest"; +import { monacoWorkerName, resolveMonacoLanguage } from "./monacoRuntime"; + +const monaco = { + languages: { + getLanguages: () => [ + { id: "dockerfile", filenames: ["Dockerfile"] }, + { id: "typescript", extensions: [".ts", ".tsx"] }, + { id: "python", extensions: [".py"] }, + ], + }, +} as unknown as typeof Monaco; + +describe("Monaco runtime routing", () => { + it("maps special filenames and extensions", () => { + expect(resolveMonacoLanguage(monaco, "/repo/Dockerfile")).toBe( + "dockerfile", + ); + expect(resolveMonacoLanguage(monaco, "/repo/App.TSX")).toBe("typescript"); + expect(resolveMonacoLanguage(monaco, "/repo/script.py")).toBe("python"); + expect(resolveMonacoLanguage(monaco, "/repo/NOTICE")).toBe("plaintext"); + }); + + it("routes language services to dedicated workers", () => { + expect(monacoWorkerName("typescript")).toBe("ts"); + expect(monacoWorkerName("json")).toBe("json"); + expect(monacoWorkerName("scss")).toBe("css"); + expect(monacoWorkerName("unknown")).toBe("editor"); + }); +}); diff --git a/src/lib/monacoRuntime.ts b/src/lib/monacoRuntime.ts new file mode 100644 index 0000000..7637a88 --- /dev/null +++ b/src/lib/monacoRuntime.ts @@ -0,0 +1,146 @@ +import native from "bridge/native"; +import type * as Monaco from "monaco-editor"; +import themes from "themes"; +import { installMonacoClipboardAdapter } from "./monacoClipboard"; +import { createMonacoTheme, monacoThemeName } from "./monacoTheme"; + +type MonacoEnvironment = { + getWorker?: (moduleId: string, label: string) => Worker; + getWorkerUrl?: (moduleId: string, label: string) => string; + globalAPI?: boolean; +}; + +const workerNames: Record = { + json: "json", + css: "css", + scss: "css", + less: "css", + html: "html", + handlebars: "html", + razor: "html", + typescript: "ts", + javascript: "ts", + editorWorkerService: "editor", +}; + +export function monacoWorkerName(label: string) { + return workerNames[label] ?? "editor"; +} + +let runtimePromise: Promise | null = null; +let macWorkerUrls: Record | null = null; +let subscribed = false; + +async function prepareMacWorkers() { + if (!process.env.IS_MACOS || macWorkerUrls) return; + const entries = await Promise.all( + ["editor", "json", "css", "html", "ts"].map(async (name) => { + const path = `monaco/${name}.worker.js`; + let source: string; + try { + const response = await fetch(new URL(path, document.baseURI)); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + source = await response.text(); + } catch { + // Some WKWebView versions reject Fetch for custom schemes. The + // trusted native bundle reader is a bounded fallback for those hosts. + source = await native.loadBundledAsset(path); + } + if (!source.trim()) + throw new Error(`Monaco ${name} worker is unavailable`); + return [ + name, + URL.createObjectURL( + new Blob([source], { type: "application/javascript" }), + ), + ] as const; + }), + ); + macWorkerUrls = Object.fromEntries(entries); +} + +async function checkWorkerHealth(monaco: typeof Monaco) { + const worker = monaco.createWebWorker({ + moduleId: "vs/editor/editor.worker", + label: "editorWorkerService", + createData: null, + }); + let timeout = 0; + try { + await Promise.race([ + worker.getProxy(), + new Promise((_, reject) => { + timeout = window.setTimeout( + () => reject(new Error("Monaco editor worker did not start")), + 4_000, + ); + }), + ]); + } finally { + window.clearTimeout(timeout); + worker.dispose(); + } +} + +function installMacWorkerFactory() { + if (!process.env.IS_MACOS || !macWorkerUrls) return; + const target = globalThis as typeof globalThis & { + MonacoEnvironment?: MonacoEnvironment; + }; + target.MonacoEnvironment = { + ...target.MonacoEnvironment, + getWorker(_moduleId, label) { + const name = monacoWorkerName(label); + const url = macWorkerUrls?.[name]; + if (!url) throw new Error(`Monaco worker ${name} was not prepared`); + return new Worker(url); + }, + }; +} + +function applyTheme(monaco: typeof Monaco) { + const theme = themes.current; + if (!theme) return; + const name = monacoThemeName(theme); + monaco.editor.defineTheme(name, createMonacoTheme(theme)); + monaco.editor.setTheme(name); +} + +export function loadMonaco() { + if (!runtimePromise) { + runtimePromise = (async () => { + if (process.env.IS_MACOS) installMonacoClipboardAdapter(); + await prepareMacWorkers(); + installMacWorkerFactory(); + const monaco = await import("monaco-editor"); + installMacWorkerFactory(); + await checkWorkerHealth(monaco); + applyTheme(monaco); + if (!subscribed) { + subscribed = true; + themes.subscribe(() => applyTheme(monaco)); + } + return monaco; + })().catch((error) => { + runtimePromise = null; + throw error; + }); + } + return runtimePromise; +} + +export function resolveMonacoLanguage(monaco: typeof Monaco, path: string) { + const name = path.split("/").pop()?.toLowerCase() ?? path.toLowerCase(); + const dot = name.lastIndexOf("."); + const extension = dot >= 0 ? name.slice(dot) : ""; + for (const language of monaco.languages.getLanguages()) { + if ( + language.filenames?.some((candidate) => candidate.toLowerCase() === name) + ) { + return language.id; + } + if (extension && language.extensions?.includes(extension)) + return language.id; + } + return "plaintext"; +} diff --git a/src/lib/monacoTheme.test.ts b/src/lib/monacoTheme.test.ts new file mode 100644 index 0000000..e6ff3e7 --- /dev/null +++ b/src/lib/monacoTheme.test.ts @@ -0,0 +1,107 @@ +import darkTheme from "themes/dark"; +import { toThemeId } from "themes/id"; +import lightTheme from "themes/light"; +import oledTheme from "themes/oled"; +import { + catppuccinMochaTheme, + draculaTheme, + githubDarkTheme, + githubLightTheme, + oneDarkProTheme, + tokyoNightTheme, +} from "themes/popular"; +import { describe, expect, it } from "vitest"; +import { normalizeMonacoColor } from "./monacoColor"; +import { createMonacoTheme, monacoThemeName } from "./monacoTheme"; + +const MONACO_HEX = /^#[\dA-F]{6}(?:[\dA-F]{2})?$/; +const ALL_THEMES = [ + lightTheme, + darkTheme, + oledTheme, + oneDarkProTheme, + draculaTheme, + githubDarkTheme, + githubLightTheme, + tokyoNightTheme, + catppuccinMochaTheme, +] as const; + +describe("Shellular Monaco themes", () => { + it.each([ + [lightTheme, "vs"], + [darkTheme, "vs-dark"], + [oledTheme, "vs-dark"], + [oneDarkProTheme, "vs-dark"], + [draculaTheme, "vs-dark"], + [githubDarkTheme, "vs-dark"], + [githubLightTheme, "vs"], + [tokyoNightTheme, "vs-dark"], + [catppuccinMochaTheme, "vs-dark"], + ] as const)("maps %s to a complete editor theme", (theme, base) => { + const result = createMonacoTheme(theme); + expect(result.base).toBe(base); + expect(result.colors["editor.background"]).toBe( + normalizeMonacoColor(theme.primary), + ); + expect(result.colors["editor.foreground"]).toBe( + normalizeMonacoColor(theme.primaryText), + ); + expect(result.colors.focusBorder).toBe(normalizeMonacoColor(theme.accent)); + expect(result.colors["diffEditor.insertedTextBackground"]).toBeTruthy(); + expect(monacoThemeName(theme)).toContain(toThemeId(theme.name)); + }); + + it.each(ALL_THEMES)("emits only valid Monaco colors for %s", (theme) => { + const result = createMonacoTheme(theme); + for (const color of Object.values(result.colors)) { + expect(color).toMatch(MONACO_HEX); + } + for (const rule of result.rules) { + if (rule.foreground) expect(`#${rule.foreground}`).toMatch(MONACO_HEX); + } + }); + + it.each(ALL_THEMES)("matches editor chrome and scrollbars to %s", (theme) => { + const colors = createMonacoTheme(theme).colors; + expect(colors["editorLineNumber.foreground"]).toBe( + normalizeMonacoColor(theme.textMuted), + ); + expect(colors["editor.lineHighlightBackground"]).toBe( + normalizeMonacoColor(theme.surfaceSoft), + ); + expect(colors["editorGutter.background"]).toBe( + normalizeMonacoColor(theme.primary), + ); + expect(colors["scrollbarSlider.background"]).toBe( + normalizeMonacoColor(theme.scrollbarThumb), + ); + expect(colors["editorLineNumber.foreground"]).not.toBe("#FF0000"); + expect(colors["editor.lineHighlightBackground"]).not.toBe("#FF0000"); + expect(theme.xtermTheme.scrollbarSliderBackground).toBe( + theme.scrollbarThumb, + ); + expect(theme.xtermTheme.scrollbarSliderHoverBackground).toBe( + theme.scrollbarThumbHover, + ); + expect(theme.xtermTheme.scrollbarSliderActiveBackground).toBe( + theme.scrollbarThumbActive, + ); + }); + + it.each(ALL_THEMES)("uses the semantic syntax palette for %s", (theme) => { + const rules = createMonacoTheme(theme).rules; + const foreground = (token: string) => + rules.find((rule) => rule.token === token)?.foreground; + + expect(foreground("keyword")).toBe( + normalizeMonacoColor(theme.syntaxTheme.keyword).slice(1, 7), + ); + expect(foreground("string")).toBe( + normalizeMonacoColor(theme.syntaxTheme.string).slice(1, 7), + ); + expect(foreground("function")).toBe( + normalizeMonacoColor(theme.syntaxTheme.function).slice(1, 7), + ); + }); +}); diff --git a/src/lib/monacoTheme.ts b/src/lib/monacoTheme.ts new file mode 100644 index 0000000..f728770 --- /dev/null +++ b/src/lib/monacoTheme.ts @@ -0,0 +1,211 @@ +import type Theme from "classes/theme"; +import type { editor } from "monaco-editor"; +import { toThemeId } from "themes/id"; +import { + monacoTokenColor, + normalizeMonacoColor, + withColorAlpha, +} from "./monacoColor"; + +export function monacoThemeName(theme: Theme) { + return `shellular-${toThemeId(theme.name)}`; +} + +export function createMonacoTheme(theme: Theme): editor.IStandaloneThemeData { + const dark = theme.type !== "light"; + const syntax = theme.syntaxTheme; + const accentSelection = withColorAlpha(theme.accent, dark ? 0.24 : 0.2); + const accentSelectionInactive = withColorAlpha( + theme.accent, + dark ? 0.12 : 0.1, + ); + const colors: Record = { + "editor.background": theme.primary, + "editor.foreground": theme.primaryText, + "editorLineNumber.foreground": theme.textMuted, + "editorLineNumber.activeForeground": theme.primaryText, + "editorCursor.foreground": theme.accent, + "editor.selectionBackground": accentSelection, + "editor.inactiveSelectionBackground": accentSelectionInactive, + "editor.selectionHighlightBackground": withColorAlpha(theme.accent, 0.1), + "editor.wordHighlightBackground": withColorAlpha(theme.info, 0.12), + "editor.wordHighlightStrongBackground": withColorAlpha(theme.accent, 0.16), + "editor.findMatchBackground": withColorAlpha(theme.accent, 0.34), + "editor.findMatchHighlightBackground": withColorAlpha(theme.accent, 0.16), + "editor.lineHighlightBackground": theme.surfaceSoft, + "editor.lineHighlightBorder": "transparent", + "editorGutter.background": theme.primary, + "editorGutter.foldingControlForeground": theme.textMuted, + "editorGutter.addedBackground": theme.success, + "editorGutter.modifiedBackground": theme.info, + "editorGutter.deletedBackground": theme.danger, + "editorIndentGuide.background1": theme.lineSoft, + "editorIndentGuide.activeBackground1": withColorAlpha(theme.accent, 0.44), + "editorWhitespace.foreground": withColorAlpha(theme.textMuted, 0.24), + "editorBracketMatch.background": withColorAlpha(theme.accent, 0.12), + "editorBracketMatch.border": withColorAlpha(theme.accent, 0.58), + "editorBracketHighlight.foreground1": syntax.property, + "editorBracketHighlight.foreground2": syntax.type, + "editorBracketHighlight.foreground3": syntax.keyword, + "editorBracketHighlight.foreground4": syntax.function, + "editorBracketHighlight.foreground5": syntax.string, + "editorBracketHighlight.foreground6": syntax.regexp, + "editorBracketHighlight.unexpectedBracketForeground": theme.danger, + "editorStickyScroll.background": theme.primary, + "editorStickyScrollHover.background": theme.surfaceSoft, + "editorStickyScroll.border": theme.lineSoft, + "editorStickyScroll.shadow": "transparent", + "minimap.background": theme.primary, + "minimap.selectionHighlight": withColorAlpha(theme.accent, 0.32), + "minimap.findMatchHighlight": withColorAlpha(theme.accent, 0.46), + "minimap.errorHighlight": theme.danger, + "minimap.warningHighlight": theme.warning, + "minimapSlider.background": theme.scrollbarThumb, + "minimapSlider.hoverBackground": theme.scrollbarThumbHover, + "minimapSlider.activeBackground": theme.scrollbarThumbActive, + "editorOverviewRuler.background": theme.primary, + "editorOverviewRuler.border": theme.lineSoft, + "editorWidget.background": theme.popupBackground, + "editorWidget.foreground": theme.popupText, + "editorWidget.border": theme.popupBorderColor, + "editorHoverWidget.background": theme.popupBackground, + "editorHoverWidget.foreground": theme.popupText, + "editorHoverWidget.border": theme.popupBorderColor, + "editorHoverWidget.statusBarBackground": theme.surfaceStrong, + "editorSuggestWidget.background": theme.popupBackground, + "editorSuggestWidget.border": theme.popupBorderColor, + "editorSuggestWidget.foreground": theme.popupText, + "editorSuggestWidget.selectedBackground": theme.popupActiveBackground, + "editorSuggestWidget.selectedForeground": theme.popupActiveText, + "editorSuggestWidget.highlightForeground": theme.accent, + "editorSuggestWidget.focusHighlightForeground": theme.accent, + "input.background": theme.input, + "input.foreground": theme.inputText, + "input.border": theme.cardBorder, + "input.placeholderForeground": theme.inputPlaceholder, + "inputOption.activeBackground": withColorAlpha(theme.accent, 0.14), + "inputOption.activeBorder": theme.accent, + "inputOption.activeForeground": theme.primaryText, + "inputValidation.errorBackground": withColorAlpha(theme.danger, 0.1), + "inputValidation.errorBorder": theme.danger, + "inputValidation.warningBackground": withColorAlpha(theme.warning, 0.1), + "inputValidation.warningBorder": theme.warning, + focusBorder: theme.accent, + foreground: theme.primaryText, + "list.activeSelectionBackground": theme.popupActiveBackground, + "list.activeSelectionForeground": theme.popupActiveText, + "list.inactiveSelectionBackground": theme.surfaceSoft, + "list.inactiveSelectionForeground": theme.primaryText, + "list.hoverBackground": theme.popupHoverBackground, + "list.hoverForeground": theme.popupHoverText, + "list.focusBackground": theme.popupActiveBackground, + "list.focusForeground": theme.popupActiveText, + "menu.background": theme.popupBackground, + "menu.foreground": theme.popupText, + "menu.selectionBackground": theme.popupActiveBackground, + "menu.selectionForeground": theme.popupActiveText, + "menu.border": theme.popupBorderColor, + "menu.separatorBackground": theme.lineSoft, + "quickInput.background": theme.popupBackground, + "quickInput.foreground": theme.popupText, + "quickInputList.focusBackground": theme.popupActiveBackground, + "quickInputList.focusForeground": theme.popupActiveText, + "quickInputList.focusIconForeground": theme.accent, + "pickerGroup.border": theme.lineSoft, + "pickerGroup.foreground": theme.accent, + "breadcrumb.background": theme.primary, + "breadcrumb.foreground": theme.textMuted, + "breadcrumb.focusForeground": theme.primaryText, + "breadcrumb.activeSelectionForeground": theme.accent, + "breadcrumbPicker.background": theme.popupBackground, + "scrollbar.shadow": "transparent", + "scrollbarSlider.background": theme.scrollbarThumb, + "scrollbarSlider.hoverBackground": theme.scrollbarThumbHover, + "scrollbarSlider.activeBackground": theme.scrollbarThumbActive, + "diffEditor.insertedLineBackground": withColorAlpha(theme.success, 0.12), + "diffEditor.removedLineBackground": withColorAlpha(theme.danger, 0.14), + "diffEditor.insertedTextBackground": withColorAlpha(theme.success, 0.24), + "diffEditor.removedTextBackground": withColorAlpha(theme.danger, 0.26), + "diffEditorGutter.insertedLineBackground": withColorAlpha( + theme.success, + 0.2, + ), + "diffEditorGutter.removedLineBackground": withColorAlpha( + theme.danger, + 0.22, + ), + "diffEditorOverview.insertedForeground": withColorAlpha( + theme.success, + 0.68, + ), + "diffEditorOverview.removedForeground": withColorAlpha(theme.danger, 0.68), + "diffEditor.diagonalFill": theme.lineSoft, + }; + return { + base: dark ? "vs-dark" : "vs", + inherit: true, + rules: [ + { + token: "comment", + foreground: monacoTokenColor(syntax.comment), + fontStyle: "italic", + }, + { token: "keyword", foreground: monacoTokenColor(syntax.keyword) }, + { + token: "string", + foreground: monacoTokenColor(syntax.string), + }, + { + token: "number", + foreground: monacoTokenColor(syntax.number), + }, + { + token: "type", + foreground: monacoTokenColor(syntax.type), + }, + { + token: "type.identifier", + foreground: monacoTokenColor(syntax.type), + }, + { + token: "function", + foreground: monacoTokenColor(syntax.function), + }, + { + token: "variable", + foreground: monacoTokenColor(syntax.variable), + }, + { + token: "identifier", + foreground: monacoTokenColor(syntax.variable), + }, + { + token: "delimiter", + foreground: monacoTokenColor(syntax.operator), + }, + { + token: "tag", + foreground: monacoTokenColor(syntax.tag), + }, + { + token: "attribute.name", + foreground: monacoTokenColor(syntax.property), + }, + { + token: "regexp", + foreground: monacoTokenColor(syntax.regexp), + }, + { + token: "invalid", + foreground: monacoTokenColor(theme.danger), + fontStyle: "underline", + }, + ], + colors: Object.fromEntries( + Object.entries(colors).map(([name, value]) => [ + name, + normalizeMonacoColor(value), + ]), + ), + }; +} diff --git a/src/lib/onboarding.test.ts b/src/lib/onboarding.test.ts new file mode 100644 index 0000000..41acf62 --- /dev/null +++ b/src/lib/onboarding.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveOnboardingVisibility } from "./onboarding"; + +describe("resolveOnboardingVisibility", () => { + it("disables onboarding on macOS without reading completion state", async () => { + const readCompletion = vi.fn<() => Promise>(); + + await expect( + resolveOnboardingVisibility({ isMacos: true, readCompletion }), + ).resolves.toBe(false); + expect(readCompletion).not.toHaveBeenCalled(); + }); + + it("shows onboarding on other platforms when it is incomplete", async () => { + await expect( + resolveOnboardingVisibility({ + isMacos: false, + readCompletion: async () => null, + }), + ).resolves.toBe(true); + }); + + it("hides onboarding on other platforms after completion", async () => { + await expect( + resolveOnboardingVisibility({ + isMacos: false, + readCompletion: async () => true, + }), + ).resolves.toBe(false); + }); +}); diff --git a/src/lib/onboarding.ts b/src/lib/onboarding.ts new file mode 100644 index 0000000..efe01e9 --- /dev/null +++ b/src/lib/onboarding.ts @@ -0,0 +1,14 @@ +export const ONBOARDING_KEY = "shellular:onboarding-complete"; + +interface ResolveOnboardingVisibilityOptions { + isMacos: boolean; + readCompletion: () => Promise; +} + +export async function resolveOnboardingVisibility({ + isMacos, + readCompletion, +}: ResolveOnboardingVisibilityOptions): Promise { + if (isMacos) return false; + return !(await readCompletion()); +} diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts new file mode 100644 index 0000000..aaf2c3e --- /dev/null +++ b/src/lib/settings.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const storage = vi.hoisted(() => ({ text: "" })); + +vi.mock("bridge/file", () => ({ + default: { + exists: vi.fn(async () => true), + read: vi.fn(async () => storage.text), + write: vi.fn(async () => undefined), + }, +})); + +import { loadSettings } from "./settings"; + +describe("editor settings migration", () => { + beforeEach(() => { + storage.text = JSON.stringify({ editor: { fontSize: 16, tabSize: 2 } }); + }); + + it("defaults new desktop Monaco options for older settings files", async () => { + const settings = await loadSettings(); + expect(settings.editor).toMatchObject({ + fontSize: 16, + tabSize: 2, + minimap: true, + stickyScroll: true, + }); + }); + + it("preserves explicit Monaco preferences", async () => { + storage.text = JSON.stringify({ + editor: { minimap: false, stickyScroll: false }, + }); + const settings = await loadSettings(); + expect(settings.editor.minimap).toBe(false); + expect(settings.editor.stickyScroll).toBe(false); + }); +}); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 86162fe..cfdfd00 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,10 +1,10 @@ import file from "bridge/file"; import appConfig from "lib/appConfig"; -export type DevServerProtocol = "http" | "https"; +export type ServerProtocol = "http" | "https"; -export type DevServerSettings = { - protocol: DevServerProtocol; +export type ServerSettings = { + protocol: ServerProtocol; domain: string; }; @@ -14,6 +14,8 @@ export type EditorSettings = { wordWrap: boolean; lineNumbers: boolean; tabSize: number; + minimap: boolean; + stickyScroll: boolean; }; export type TerminalCursorStyle = "block" | "underline" | "bar"; @@ -30,7 +32,7 @@ export type TerminalSettings = { export type AppSettings = { theme: string; - devServer: DevServerSettings; + server: ServerSettings; editor: EditorSettings; terminal: TerminalSettings; showHiddenFiles: boolean; @@ -80,7 +82,7 @@ function normalizeFontFamily(value: unknown): string { : MONOSPACE_FONT_FAMILY_OPTIONS[0].value; } -export const DEFAULT_DEV_SERVER_SETTINGS: DevServerSettings = { +export const DEFAULT_SERVER_SETTINGS: ServerSettings = { protocol: "https", domain: appConfig.DEFAULT_SERVER, }; @@ -91,6 +93,8 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { wordWrap: true, lineNumbers: true, tabSize: 4, + minimap: true, + stickyScroll: true, }; export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = { @@ -105,7 +109,7 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = { const DEFAULT_SETTINGS: AppSettings = { theme: "dark", - devServer: DEFAULT_DEV_SERVER_SETTINGS, + server: DEFAULT_SERVER_SETTINGS, editor: DEFAULT_EDITOR_SETTINGS, terminal: DEFAULT_TERMINAL_SETTINGS, showHiddenFiles: false, @@ -119,17 +123,15 @@ function normalizeDomain(domain: string): string { .replace(/\/+$/, ""); } -function normalizeDevServerSettings( - settings?: Partial | null, -): DevServerSettings { +function normalizeServerSettings( + settings?: Partial | null, +): ServerSettings { return { protocol: settings?.protocol === "http" || settings?.protocol === "https" ? settings.protocol - : DEFAULT_DEV_SERVER_SETTINGS.protocol, - domain: normalizeDomain( - settings?.domain || DEFAULT_DEV_SERVER_SETTINGS.domain, - ), + : DEFAULT_SERVER_SETTINGS.protocol, + domain: normalizeDomain(settings?.domain || DEFAULT_SERVER_SETTINGS.domain), }; } @@ -154,6 +156,14 @@ function normalizeEditorSettings( typeof settings?.tabSize === "number" ? Math.min(8, Math.max(1, Math.round(settings.tabSize))) : DEFAULT_EDITOR_SETTINGS.tabSize, + minimap: + typeof settings?.minimap === "boolean" + ? settings.minimap + : DEFAULT_EDITOR_SETTINGS.minimap, + stickyScroll: + typeof settings?.stickyScroll === "boolean" + ? settings.stickyScroll + : DEFAULT_EDITOR_SETTINGS.stickyScroll, }; } @@ -216,7 +226,7 @@ function normalizeSettings( ): AppSettings { return { theme: settings?.theme || DEFAULT_SETTINGS.theme, - devServer: normalizeDevServerSettings(settings?.devServer), + server: normalizeServerSettings(settings?.server), editor: normalizeEditorSettings(settings?.editor), terminal: normalizeTerminalSettings(settings?.terminal), showHiddenFiles: @@ -254,9 +264,9 @@ export async function saveSettings( const next = normalizeSettings({ ...current, ...settings, - devServer: { - ...current.devServer, - ...settings.devServer, + server: { + ...current.server, + ...settings.server, }, editor: { ...current.editor, @@ -275,6 +285,6 @@ export async function saveSettings( export async function getBaseServerUrl(): Promise { const settings = await loadSettings(); - const domain = normalizeDomain(settings.devServer.domain); - return `${settings.devServer.protocol}://${domain || DEFAULT_DEV_SERVER_SETTINGS.domain}`; + const domain = normalizeDomain(settings.server.domain); + return `${settings.server.protocol}://${domain || DEFAULT_SERVER_SETTINGS.domain}`; } diff --git a/src/lib/terminalResize.test.ts b/src/lib/terminalResize.test.ts new file mode 100644 index 0000000..3cb8d64 --- /dev/null +++ b/src/lib/terminalResize.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTerminalResizeThrottle } from "./terminalResize"; + +describe("createTerminalResizeThrottle", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("sends the first dimensions immediately and coalesces a burst", () => { + const send = vi.fn(); + const throttle = createTerminalResizeThrottle(send, 100); + + throttle.schedule({ cols: 80, rows: 24 }); + throttle.schedule({ cols: 90, rows: 30 }); + throttle.schedule({ cols: 100, rows: 32 }); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenLastCalledWith({ cols: 80, rows: 24 }); + vi.advanceTimersByTime(100); + expect(send).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenLastCalledWith({ cols: 100, rows: 32 }); + }); + + it("flushes the exact final dimensions", () => { + const send = vi.fn(); + const throttle = createTerminalResizeThrottle(send, 100); + throttle.schedule({ cols: 80, rows: 24 }); + throttle.schedule({ cols: 120, rows: 40 }); + + throttle.flush(); + + expect(send).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenLastCalledWith({ cols: 120, rows: 40 }); + }); + + it("does not send duplicates or pending work after disposal", () => { + const send = vi.fn(); + const throttle = createTerminalResizeThrottle(send, 100); + throttle.schedule({ cols: 80, rows: 24 }); + throttle.schedule({ cols: 80, rows: 24 }); + throttle.schedule({ cols: 90, rows: 30 }); + throttle.dispose(); + vi.runAllTimers(); + + expect(send).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/terminalResize.ts b/src/lib/terminalResize.ts new file mode 100644 index 0000000..1f95d27 --- /dev/null +++ b/src/lib/terminalResize.ts @@ -0,0 +1,59 @@ +export interface TerminalDimensions { + cols: number; + rows: number; +} + +function sameDimensions( + left: TerminalDimensions | null, + right: TerminalDimensions | null, +) { + return left?.cols === right?.cols && left?.rows === right?.rows; +} + +export function createTerminalResizeThrottle( + send: (dimensions: TerminalDimensions) => void, + interval = 100, +) { + let disposed = false; + let lastSentAt = Number.NEGATIVE_INFINITY; + let lastSent: TerminalDimensions | null = null; + let pending: TerminalDimensions | null = null; + let timer: ReturnType | undefined; + + const deliver = () => { + timer = undefined; + if (disposed || !pending) return; + const next = pending; + pending = null; + if (sameDimensions(next, lastSent)) return; + lastSent = next; + lastSentAt = Date.now(); + send(next); + }; + + const schedule = (dimensions: TerminalDimensions) => { + if (disposed || sameDimensions(dimensions, pending)) return; + pending = dimensions; + const remaining = interval - (Date.now() - lastSentAt); + if (remaining <= 0) { + if (timer) clearTimeout(timer); + deliver(); + } else if (!timer) { + timer = setTimeout(deliver, remaining); + } + }; + + const flush = () => { + if (timer) clearTimeout(timer); + deliver(); + }; + + const dispose = () => { + disposed = true; + pending = null; + if (timer) clearTimeout(timer); + timer = undefined; + }; + + return { schedule, flush, dispose }; +} diff --git a/src/lib/windowResize.test.ts b/src/lib/windowResize.test.ts new file mode 100644 index 0000000..608ea66 --- /dev/null +++ b/src/lib/windowResize.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("window resize lifecycle", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + document.body.classList.remove("resizing"); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.classList.remove("resizing"); + }); + + it("emits one start and one settled event for a browser resize burst", async () => { + const resize = (await import("./windowResize")).default; + const onStart = vi.fn(); + const onSettled = vi.fn(); + resize.on("resizeStart", onStart); + resize.on("resize", onSettled); + + resize(); + resize(); + expect(document.body).toHaveClass("resizing"); + expect(onStart).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(100); + expect(onSettled).toHaveBeenCalledOnce(); + expect(document.body).not.toHaveClass("resizing"); + }); + + it("uses native start and end signals without waiting for the fallback timer", async () => { + const resize = (await import("./windowResize")).default; + const onStart = vi.fn(); + const onSettled = vi.fn(); + resize.on("resizeStart", onStart); + resize.on("resize", onSettled); + + resize.start(); + resize.start(); + expect(onStart).toHaveBeenCalledOnce(); + expect(document.body).toHaveClass("resizing"); + + resize.end(); + expect(onSettled).toHaveBeenCalledOnce(); + expect(document.body).not.toHaveClass("resizing"); + }); + + it("does not emit a settled event without a matching start", async () => { + const resize = (await import("./windowResize")).default; + const onSettled = vi.fn(); + resize.on("resize", onSettled); + + resize.end(); + + expect(onSettled).not.toHaveBeenCalled(); + }); + + it("suppresses fallback resize timers during a native resize session", async () => { + const resize = (await import("./windowResize")).default; + const onStart = vi.fn(); + const onSettled = vi.fn(); + resize.on("resizeStart", onStart); + resize.on("resize", onSettled); + + resize.start(); + resize(); + resize(); + vi.advanceTimersByTime(500); + expect(onStart).toHaveBeenCalledOnce(); + expect(onSettled).not.toHaveBeenCalled(); + + resize.end(); + expect(onSettled).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/windowResize.ts b/src/lib/windowResize.ts index 012200b..3aa4982 100644 --- a/src/lib/windowResize.ts +++ b/src/lib/windowResize.ts @@ -2,6 +2,7 @@ type ResizeEvent = "resize" | "resizeStart"; type ResizeEventListener = () => void; let resizeTimeout: ReturnType | null = null; +let nativeResizeActive = false; const event: Record = { resize: [], @@ -24,15 +25,33 @@ const event: Record = { */ export default function windowResize() { - if (!resizeTimeout) { - document.body.classList.add("resizing"); - emit("resizeStart"); - } + if (nativeResizeActive) return; + startResize(); if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = setTimeout(onResize, 100); } +function startResize() { + if (document.body.classList.contains("resizing")) return; + document.body.classList.add("resizing"); + emit("resizeStart"); +} + +windowResize.start = () => { + nativeResizeActive = true; + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeTimeout = null; + startResize(); +}; + +windowResize.end = () => { + nativeResizeActive = false; + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeTimeout = null; + onResize(); +}; + /** * Add event listener for window done resizing. * @param {ResizeEvent} eventName @@ -64,6 +83,7 @@ windowResize.off = (eventName: ResizeEvent, callback: ResizeEventListener) => { */ function onResize() { resizeTimeout = null; + if (!document.body.classList.contains("resizing")) return; emit("resize"); document.body.classList.remove("resizing"); } diff --git a/src/main.scss b/src/main.scss index 1007243..7f142c2 100644 --- a/src/main.scss +++ b/src/main.scss @@ -3,16 +3,16 @@ // ── Tailwind foundation ───────────────────────────────────────────── // Platform / keyboard variants mirror the SCSS mixins in src/mixins.scss so -// `ios:`, `android:`, `browser:`, `kbd:` (+ composites) are usable as idiomatic +// `ios:`, `android:`, `desktop-ui:`, `kbd:` (+ composites) are usable as idiomatic // Tailwind variants during the SCSS→Tailwind migration. `md:` (768px) and `lg:` // (1024px) already cover the old `tablet`/`desktop` mixins. @custom-variant ios (body[platform="ios"] &); @custom-variant android (body[platform="android"] &); -@custom-variant browser (body[platform="browser"] &); +@custom-variant desktop-ui (body[ui-platform="desktop"] &); @custom-variant kbd (body.keyboard-visible &); @custom-variant ios-kbd (body[platform="ios"].keyboard-visible &); @custom-variant android-kbd (body[platform="android"].keyboard-visible &); -@custom-variant browser-kbd (body[platform="browser"].keyboard-visible &); +@custom-variant desktop-ui-kbd (body[ui-platform="desktop"].keyboard-visible &); @custom-variant big-phone (@media (max-width: 520px)); // Bridge the runtime theme tokens (emitted as :root { --accent; ... } by @@ -47,6 +47,11 @@ --color-popup-active-text: var(--popup-active-text); --color-shadow-color: var(--shadow-color); } + +.card-subtext { + color: var(--secondary-text); + opacity: var(--card-subtext-opacity, 0.6); +} // ──────────────────────────────────────────────────────────────────── @font-face { @@ -105,12 +110,66 @@ body { user-select: none; font-size: 18px; -webkit-tap-highlight-color: transparent; +} - @include browser { - ::-webkit-scrollbar { - display: none; - } - } +// Keep desktop scroll owners visually consistent across browser and WKWebView. +// This must target the desktop body directly: nesting the desktop-ui mixin from +// inside `body` produces the impossible `body[ui-platform="desktop"] body`. +body[ui-platform="desktop"] * { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +body[ui-platform="desktop"] *::-webkit-scrollbar { + width: 10px; + height: 10px; + background: var(--scrollbar-track); +} + +body[ui-platform="desktop"] *::-webkit-scrollbar-track, +body[ui-platform="desktop"] *::-webkit-scrollbar-corner { + background: var(--scrollbar-track); +} + +body[ui-platform="desktop"] *::-webkit-scrollbar-thumb { + min-width: 32px; + min-height: 32px; + border: 2px solid transparent; + border-radius: 999px; + background-color: var(--scrollbar-thumb); + background-clip: padding-box; +} + +body[ui-platform="desktop"] *::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover); +} + +body[ui-platform="desktop"] *::-webkit-scrollbar-thumb:active { + background-color: var(--scrollbar-thumb-active); +} + +// Decorative infinite animation competes with layout and raster work during a +// live resize. Pause it briefly without changing editor or cursor animations. +body[ui-platform="desktop"].resizing { + *, + *::before, + *::after { + transition-duration: 0s !important; + scroll-behavior: auto !important; + } + + .mascot *, + .loader-icon { + animation-play-state: paused !important; + } +} + +.desktop-scroll-area { + overscroll-behavior: contain; +} + +.desktop-scroll-area-stable { + scrollbar-gutter: stable; } #app { @@ -187,4 +246,4 @@ body { */ .haptic-trigger { cursor: pointer; -} \ No newline at end of file +} diff --git a/src/main.ts b/src/main.ts index 7529440..3e987ed 100644 --- a/src/main.ts +++ b/src/main.ts @@ -132,8 +132,14 @@ async function load() { document.body.dataset.message = bootMessage; document.body.classList.remove("icon-empty"); document.body.setAttribute("platform", process.env.PLATFORM); + document.body.setAttribute( + "ui-platform", + process.env.IS_DESKTOP_UI ? "desktop" : "mobile", + ); document.addEventListener("backbutton", actionStack.pop); window.addEventListener("resize", windowResize); + window.addEventListener("shellular:native-resize-start", windowResize.start); + window.addEventListener("shellular:native-resize-end", windowResize.end); keyboard.on("show", whenKeyboardShow); keyboard.on("hide", whenKeyboardHide); document.addEventListener("click", externalLinks); diff --git a/src/mixins.scss b/src/mixins.scss index 4bffdf2..89dd436 100644 --- a/src/mixins.scss +++ b/src/mixins.scss @@ -40,14 +40,14 @@ } } -@mixin browser { - body[platform="browser"] & { +@mixin desktop-ui { + body[ui-platform="desktop"] & { @content; } } -@mixin browser-keyboard-visible { - body[platform="browser"].keyboard-visible & { +@mixin desktop-ui-keyboard-visible { + body[ui-platform="desktop"].keyboard-visible & { @content; } } @@ -64,4 +64,4 @@ [class*=' icon-'] { @content; } -} \ No newline at end of file +} diff --git a/src/pages/about/index.tsx b/src/pages/about/index.tsx index 8515a9e..8368295 100644 --- a/src/pages/about/index.tsx +++ b/src/pages/about/index.tsx @@ -1,6 +1,4 @@ import { pushPage } from "App"; -import browser from "bridge/browser"; -import native from "bridge/native"; import Page from "components/Page"; import { AnimatePresence, motion } from "framer-motion"; import ReachOutPage from "pages/reach-out"; @@ -10,6 +8,8 @@ import betterKeepIcon from "res/better_keep.png"; import chess69Icon from "res/chess69.png"; import licenses from "./licenses"; import "./style.scss"; +import { openBrowserSurface } from "workbench/browserSurface"; +import { tryOpenUtilitySurface } from "workbench/openers"; export default function AboutPage() { const [expanded, setExpanded] = useState>({}); @@ -19,7 +19,7 @@ export default function AboutPage() { } return ( - +
); } @@ -194,6 +150,7 @@ function ChatTabRow({ type="button" className="project-chat-session-main haptic-trigger" onClick={onSelect} + aria-current={active ? "page" : undefined} > {agent ? ( @@ -215,7 +172,7 @@ function ChatTabRow({ type="button" className="project-chat-session-close haptic-trigger" onClick={onClose} - aria-label="Close chat" + aria-label={`Close ${tab.title}`} >