@xfey/tutti 0.1.54 → 0.1.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +5 -2
  2. package/desktop-assets/README.md +13 -0
  3. package/desktop-assets/Tutti-square.png +0 -0
  4. package/desktop-assets/Tutti.icns +0 -0
  5. package/desktop-assets/macos-handler.applescript +24 -0
  6. package/dist/server-shell/cli/args.d.ts +7 -0
  7. package/dist/server-shell/cli/args.js +32 -0
  8. package/dist/server-shell/cli/cli.js +76 -1
  9. package/dist/server-shell/cli/errors.d.ts +1 -1
  10. package/dist/server-shell/cli/version.d.ts +6 -0
  11. package/dist/server-shell/cli/version.js +11 -2
  12. package/dist/server-shell/desktop-integration/index.d.ts +5 -0
  13. package/dist/server-shell/desktop-integration/index.js +5 -0
  14. package/dist/server-shell/desktop-integration/linux.d.ts +12 -0
  15. package/dist/server-shell/desktop-integration/linux.js +186 -0
  16. package/dist/server-shell/desktop-integration/macos.d.ts +9 -0
  17. package/dist/server-shell/desktop-integration/macos.js +138 -0
  18. package/dist/server-shell/desktop-integration/manager.d.ts +40 -0
  19. package/dist/server-shell/desktop-integration/manager.js +223 -0
  20. package/dist/server-shell/desktop-integration/platform.d.ts +23 -0
  21. package/dist/server-shell/desktop-integration/platform.js +21 -0
  22. package/dist/server-shell/desktop-integration/protocol.d.ts +9 -0
  23. package/dist/server-shell/desktop-integration/protocol.js +28 -0
  24. package/dist/server-shell/desktop-integration/record.d.ts +17 -0
  25. package/dist/server-shell/desktop-integration/record.js +74 -0
  26. package/dist/server-shell/local-console/invocation-context.d.ts +7 -0
  27. package/dist/server-shell/local-console/invocation-context.js +8 -9
  28. package/dist/server-shell/local-console/managed-console.d.ts +1 -0
  29. package/dist/server-shell/local-console/managed-console.js +10 -5
  30. package/dist/server-shell/local-console/project-service.js +1 -1
  31. package/dist/server-shell/local-console/server.js +23 -6
  32. package/package.json +2 -1
  33. package/web/assets/{homepage-motion-scene-C96Na8TV.js → homepage-motion-scene-BB1uSUBr.js} +1 -1
  34. package/web/assets/{index-nWy1QP97.css → index-8lX8GhfZ.css} +1 -1
  35. package/web/assets/{index-CM41hrQd.js → index-DxkJrQ7e.js} +13 -14
  36. package/web/index.html +2 -2
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  - `tutti launch` 后的项目绑定、恢复和接管入口
10
10
  - 裸 `tutti` 经 lifecycle lock 启动 / 复用的版本感知 loopback Local Console、独立 invocation context,以及既有项目 Host 编排
11
+ - 首次 eligible 裸 `tutti`、显式 `tutti desktop` 与严格 `tutti://projects` bridge 共用的用户级 desktop integration manager
11
12
  - Fastify API、SSE、packaged static Web 产物服务和 server 生命周期
12
13
  - SQLite store、migrations、repository 与协作真相读写
13
14
  - `control-plane`、scheduler、`procedure-engine`、`run-engine`、`workspace-ops` 与 OpenAI provider 接入
@@ -18,18 +19,20 @@
18
19
  - `src/`:server 代码根目录。
19
20
  - `prompts/`:大模型 prompt、指令模板和 Codex 执行模板的统一保存目录。
20
21
  - `migrations/`:本地 `tutti.sqlite` 的 SQL migrations。
22
+ - `desktop-assets/`:随 npm package 发布的 macOS AppleScriptObjC handler、白底 squircle desktop PNG / ICNS 与资产说明。
21
23
  - `cli-package.mjs`:生成临时 staging `@xfey/tutti` package artifact 的脚本。
22
24
  - `prepare-package-assets.mjs`:把 Web build output 和内部 runtime package 复制到 staging package 所需位置。
23
25
  - `pack-dry-run.mjs`:构建 staging package 并执行 `npm pack --dry-run --json` 文件列表校验。
24
26
  - `smoke-packed-cli.mjs`:安装并验证 packed CLI tarball 的本地 smoke,包括 CLI help / version、runtime deps 和 QR dependency。
27
+ - `smoke-desktop-protocol.mjs`:在真实 `~/Applications` 下使用唯一临时 bundle / scheme 验证 macOS 图标点击与 LaunchServices URL event,并在结束时注销、清理。
25
28
 
26
29
  ## 当前状态
27
30
 
28
- - 已具备 Fastify host shell、后台 host manager CLI 套件、带 `service status / stop / restart`、卸载前 `shutdown`、并发启动锁、版本替换、独立 browser context、idle / package update cleanup 与私有日志的机器级 Local Console、Dev Runner、Host Project API、SSE、SQLite store、collaboration-state、workspace read-state、project timeline observability helper、Control Plane、Procedure / Run、workspace-ops、checks、Relay staged upload finalize、user skills 文件服务、artifacts 可视化产物查看层和 OpenAI / Codex provider 接入。
31
+ - 已具备 Fastify host shell、后台 host manager CLI 套件、用户级 desktop integration / strict `tutti://projects` bridge、带 `service status / stop / restart`、卸载前 `shutdown`、并发启动锁、版本替换、strict terminal / desktop browser context、idle / package update cleanup 与私有日志的机器级 Local Console、Dev Runner、Host Project API、SSE、SQLite store、collaboration-state、workspace read-state、project timeline observability helper、Control Plane、Procedure / Run、workspace-ops、checks、Relay staged upload finalize、user skills 文件服务、artifacts 可视化产物查看层和 OpenAI / Codex provider 接入。
29
32
  - 新 Host project 的空群聊会由 collaboration-state 幂等写入一条 Tutti 欢迎消息;它不经过 browser message command,不触发 `@tutti`、Chat read-state、Scratchpad 或 Timeline / execution 流程。
30
33
  - Host server 与 Local Console 支持在显式 `webStatic.root` 或 packaged `web/` 存在时服务 Web 静态产物;`/api/*`、SSE、health、host-local control 和 `/local-console/*` API 不被 SPA fallback 吞掉。
31
34
  - Root `pack:dry-run` 会先清理 build output、重新构建,再复制 `apps/web/dist` 到 package-local `web/`,生成临时 staging `@xfey/tutti` artifact,并校验 npm pack 文件列表包含 CLI、migrations、prompts、Web index、bundled `@tutti/shared` / `@tutti/relay-client` 以及它们的外部 runtime dependencies,同时排除源码、source map、日志、SQLite、`.env`、`.tutti-dev` 与测试产物。
32
- - Root `smoke:packed-cli` 同样从干净 build output 生成 staging `@xfey/tutti` tarball、在临时目录安装,并验证 SQLite native binding、`tutti --help` / `tutti service --help` / `tutti service status` / `tutti shutdown --help` / `tutti launch --help` / `tutti archive --help` / `tutti --version`、migrations、prompts、Web static index、bundled internal dependencies、bundled external runtime dependencies、CLI QR dependency、仓库锁定的 `@openai/codex` binary 可执行性和临时全局安装路径;该 smoke 不需要 Tutti source repo 存在。
35
+ - Root `smoke:packed-cli` 同样从干净 build output 生成 staging `@xfey/tutti` tarball、在临时目录安装,并验证 SQLite native binding、`tutti --help` / desktop help / `tutti service --help` / `tutti service status` / `tutti shutdown --help` / `tutti launch --help` / `tutti archive --help` / `tutti --version`、desktop assets、migrations、prompts、Web static index、bundled internal dependencies、bundled external runtime dependencies、CLI QR dependency、仓库锁定的 `@openai/codex` binary 可执行性和临时全局安装路径;该 smoke 不需要 Tutti source repo 存在。
33
36
  - 对外发布必须把同一 staging package 通过 `npm pack` 生成的已核验 `.tgz` 交给 `npm publish`,不得直接发布 staging 目录;npm 的目录发布路径可能重新收集文件并遗漏 staging 内的 bundled dependencies。发布后必须从 registry 临时执行 CLI version / help smoke。
34
37
  - `prompts/` 是所有模型-facing prompt、指令模板和 Codex 执行模板的统一落点;长 prompt 不内联在 TypeScript 中。
35
38
  - Host Project API 只信任 Relay tunnel metadata 注入的 `relay_session_context`;direct loopback 缺少可信 context 时不作为浏览器工作区入口。
@@ -0,0 +1,13 @@
1
+ # Desktop assets
2
+
3
+ This directory contains package-owned assets used by the user-level Tutti desktop integration.
4
+
5
+ - `macos-handler.applescript` is compiled into `~/Applications/Tutti.app` by the packaged CLI. Its
6
+ AppleScriptObjC handler launches the hidden desktop bridge through `NSTask`, with a fixed
7
+ `tutti://projects` desktop intent and no shell invocation.
8
+ - `Tutti-square.png` is the 1024px desktop icon: a white macOS-style continuous-corner squircle
9
+ with the canonical Tutti wordmark enlarged inside a safe inset. Linux uses the same visual.
10
+ - `Tutti.icns` is the multiresolution macOS app icon generated from that desktop PNG.
11
+
12
+ The assets are shipped inside `@xfey/tutti`; installation never downloads runtime files. They do
13
+ not contain project paths, credentials, tokens, Relay sessions, or Local Console endpoints.
Binary file
Binary file
@@ -0,0 +1,24 @@
1
+ use framework "Foundation"
2
+ use scripting additions
3
+
4
+ property nodeExecutable : __TUTTI_NODE_EXECUTABLE_STRING__
5
+ property cliEntrypoint : __TUTTI_CLI_ENTRYPOINT_STRING__
6
+ property projectsUrl : "tutti://projects"
7
+
8
+ on launchTutti(targetUrl)
9
+ set task to current application's NSTask's alloc()'s init()
10
+ task's setLaunchPath:nodeExecutable
11
+ task's setArguments:{cliEntrypoint, "internal", "desktop-open", targetUrl}
12
+ task's setStandardInput:(current application's NSFileHandle's fileHandleWithNullDevice())
13
+ task's setStandardOutput:(current application's NSFileHandle's fileHandleWithNullDevice())
14
+ task's setStandardError:(current application's NSFileHandle's fileHandleWithNullDevice())
15
+ task's |launch|()
16
+ end launchTutti
17
+
18
+ on run
19
+ launchTutti(projectsUrl)
20
+ end run
21
+
22
+ on open location targetUrl
23
+ launchTutti(targetUrl)
24
+ end open location
@@ -5,6 +5,9 @@ export type CliCommand = {
5
5
  topic?: string;
6
6
  } | {
7
7
  kind: "version";
8
+ } | {
9
+ kind: "desktop";
10
+ action: "install" | "status" | "remove";
8
11
  } | {
9
12
  kind: "service";
10
13
  action: "status" | "stop" | "restart";
@@ -22,6 +25,9 @@ export type CliCommand = {
22
25
  yes: boolean;
23
26
  } | {
24
27
  kind: "internal-console-run";
28
+ } | {
29
+ kind: "internal-desktop-open";
30
+ url?: string;
25
31
  } | {
26
32
  kind: "ps";
27
33
  manage: boolean;
@@ -59,6 +65,7 @@ export type LaunchCliArgs = {
59
65
  target?: string;
60
66
  yes: boolean;
61
67
  };
68
+ export declare function shouldEnsureDesktopIntegrationAutomatically(command: CliCommand): boolean;
62
69
  export declare function parseLaunchCliArgs(argv: readonly string[]): LaunchCliArgs;
63
70
  export declare function parseCliArgs(argv: readonly string[], cwd?: string): CliCommand;
64
71
  //# sourceMappingURL=args.d.ts.map
@@ -1,5 +1,8 @@
1
1
  import { resolve } from "node:path";
2
2
  import { LaunchError } from "./errors.js";
3
+ export function shouldEnsureDesktopIntegrationAutomatically(command) {
4
+ return command.kind === "console";
5
+ }
3
6
  function isHelpArg(arg) {
4
7
  return arg === "--help" || arg === "-h";
5
8
  }
@@ -58,6 +61,25 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
58
61
  if (command === "--version" || command === "-v" || command === "version") {
59
62
  return { kind: "version" };
60
63
  }
64
+ if (command === "desktop") {
65
+ const action = args.shift();
66
+ if (action === undefined) {
67
+ return { kind: "desktop", action: "install" };
68
+ }
69
+ if (isHelpArg(action)) {
70
+ return { kind: "help", topic: "desktop" };
71
+ }
72
+ if (args.length > 0) {
73
+ if (hasHelpArg(args)) {
74
+ return { kind: "help", topic: "desktop" };
75
+ }
76
+ throw new LaunchError("desktop_integration_unavailable", `Unsupported desktop option: ${args[0] ?? ""}`, "Run `tutti desktop --help` to see supported desktop commands.");
77
+ }
78
+ if (action !== "status" && action !== "remove") {
79
+ throw new LaunchError("desktop_integration_unavailable", `Unsupported desktop command: ${action}`, "Run `tutti desktop --help` to see supported desktop commands.");
80
+ }
81
+ return { kind: "desktop", action };
82
+ }
61
83
  if (command === "service") {
62
84
  const action = args.shift();
63
85
  if (action === undefined || isHelpArg(action)) {
@@ -122,6 +144,16 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
122
144
  }
123
145
  return { kind: "internal-console-run" };
124
146
  }
147
+ if (internalCommand === "desktop-open") {
148
+ if (args.length > 1) {
149
+ throw new LaunchError("desktop_protocol_invalid", "Internal desktop-open accepts only one URL", "Desktop protocol requests are managed by Tutti.");
150
+ }
151
+ const url = args[0];
152
+ return {
153
+ kind: "internal-desktop-open",
154
+ ...(url === undefined ? {} : { url }),
155
+ };
156
+ }
125
157
  if (internalCommand !== "host-run") {
126
158
  throw new LaunchError("git_bootstrap_failed", "Unsupported internal command", "Internal commands are managed by Tutti.");
127
159
  }
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ import { dirname, resolve } from "node:path";
2
3
  import { formatCliError, formatCliErrorReason, LaunchError } from "./errors.js";
3
- import { parseCliArgs } from "./args.js";
4
+ import { parseCliArgs, shouldEnsureDesktopIntegrationAutomatically } from "./args.js";
4
5
  import { confirmTextFromTty, runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, } from "./launch-command.js";
5
6
  import { resolveExistingProjectContext } from "./project-resolver.js";
6
7
  import { runProviderSetupTui } from "./provider-tui.js";
7
8
  import { runArchiveCommand, runDoctorCommand, runForgetCommand, runInviteCommand, runLogsCommand, runProviderStatusCommand, runPsManageCommand, runPsCommand, runStopCommand, listRuntimeProjects, } from "./runtime-commands.js";
8
9
  import { readCliVersion } from "./version.js";
10
+ import { ensureDesktopIntegrationAutomatically, parseTuttiDesktopUrl, runDesktopIntegrationCommand, TUTTI_DESKTOP_PROJECTS_URL, TuttiDesktopProtocolError, } from "../desktop-integration/index.js";
9
11
  import { ensureLocalConsole, openLocalConsoleBrowser, readLocalConsoleServiceStatus, restartLocalConsoleService, runLocalConsoleProcess, stopLocalConsoleService, } from "../local-console/index.js";
10
12
  const HELP_TEXT = `Usage:
11
13
  tutti
12
14
  tutti launch [target] [--yes] [--foreground]
15
+ tutti desktop [status|remove]
13
16
  tutti service <status|stop|restart>
14
17
  tutti ps [--manage] [--all]
15
18
  tutti invite [target]
@@ -61,6 +64,16 @@ Commands:
61
64
  stop Stop only the local browser service; project hosts keep running.
62
65
  restart Replace the local browser service with the current CLI version.
63
66
  `;
67
+ const DESKTOP_HELP_TEXT = `Usage:
68
+ tutti desktop
69
+ tutti desktop status
70
+ tutti desktop remove
71
+
72
+ Commands:
73
+ desktop Install or repair the user-level Tutti app and tutti:// handler.
74
+ desktop status Inspect the managed desktop entry without starting Tutti.
75
+ desktop remove Remove only the managed desktop entry and protocol handler.
76
+ `;
64
77
  const SHUTDOWN_HELP_TEXT = `Usage:
65
78
  tutti shutdown [--yes]
66
79
 
@@ -125,6 +138,9 @@ function helpForTopic(topic) {
125
138
  if (topic === "service") {
126
139
  return SERVICE_HELP_TEXT;
127
140
  }
141
+ if (topic === "desktop") {
142
+ return DESKTOP_HELP_TEXT;
143
+ }
128
144
  if (topic === "shutdown") {
129
145
  return SHUTDOWN_HELP_TEXT;
130
146
  }
@@ -184,6 +200,15 @@ async function runProviderSetupCommand(target) {
184
200
  ].join("\n"));
185
201
  process.stdout.write("\n");
186
202
  }
203
+ function ensureDesktopIntegrationForConsole() {
204
+ const desktop = ensureDesktopIntegrationAutomatically();
205
+ if (desktop.status === "unavailable" &&
206
+ (desktop.reason_code === "record_invalid" ||
207
+ desktop.reason_code === "entry_conflict" ||
208
+ desktop.reason_code === "operation_failed")) {
209
+ process.stderr.write("Tutti could not update its desktop entry. Run `tutti desktop` for details.\n");
210
+ }
211
+ }
187
212
  async function runLocalConsoleCommand() {
188
213
  const console = await ensureLocalConsole();
189
214
  if (openLocalConsoleBrowser(console.url)) {
@@ -192,6 +217,47 @@ async function runLocalConsoleCommand() {
192
217
  }
193
218
  process.stdout.write(["Open Tutti in a browser:", console.url, ""].join("\n"));
194
219
  }
220
+ function desktopResultMessage(result) {
221
+ if (result.status === "installed") {
222
+ return [
223
+ "Tutti desktop: installed",
224
+ `Entry: ${result.entry_path}`,
225
+ "Protocol: tutti://projects",
226
+ ].join("\n");
227
+ }
228
+ if (result.status === "removed") {
229
+ return ["Tutti desktop: removed", `Entry: ${result.entry_path}`].join("\n");
230
+ }
231
+ if (result.status === "skipped") {
232
+ return "Tutti desktop: removed\nRun `tutti desktop` to install it again.";
233
+ }
234
+ throw new LaunchError("desktop_integration_unavailable", result.reason, result.reason_code === "package_not_global"
235
+ ? "Install Tutti globally with `npm install -g @xfey/tutti`, then run `tutti desktop`."
236
+ : "Resolve the reported desktop entry conflict or platform requirement, then run `tutti desktop` again.");
237
+ }
238
+ function runDesktopCommand(action) {
239
+ return desktopResultMessage(runDesktopIntegrationCommand(action));
240
+ }
241
+ async function runDesktopOpenCommand(value) {
242
+ const url = value ?? TUTTI_DESKTOP_PROJECTS_URL;
243
+ try {
244
+ parseTuttiDesktopUrl(url);
245
+ }
246
+ catch (error) {
247
+ if (error instanceof TuttiDesktopProtocolError) {
248
+ throw new LaunchError("desktop_protocol_invalid", error.message, "Open Tutti from its desktop icon or use the fixed tutti://projects URL.");
249
+ }
250
+ throw error;
251
+ }
252
+ const cliEntrypoint = process.argv[1];
253
+ const console = await ensureLocalConsole({
254
+ source: "desktop",
255
+ ...(cliEntrypoint === undefined ? {} : { cwd: dirname(resolve(cliEntrypoint)) }),
256
+ });
257
+ if (!openLocalConsoleBrowser(console.url)) {
258
+ throw new LaunchError("desktop_open_failed", "The Local Console started, but no desktop browser opener is available.", "Run `tutti` from a terminal to print a one-time browser URL.");
259
+ }
260
+ }
195
261
  async function runServiceCommand(action) {
196
262
  if (action === "status") {
197
263
  const status = await readLocalConsoleServiceStatus();
@@ -272,6 +338,9 @@ async function runShutdownCommand(yes) {
272
338
  }
273
339
  try {
274
340
  const command = parseCliArgs(process.argv.slice(2));
341
+ if (shouldEnsureDesktopIntegrationAutomatically(command)) {
342
+ ensureDesktopIntegrationForConsole();
343
+ }
275
344
  switch (command.kind) {
276
345
  case "console":
277
346
  await runLocalConsoleCommand();
@@ -282,6 +351,9 @@ try {
282
351
  case "version":
283
352
  process.stdout.write(`${readCliVersion()}\n`);
284
353
  break;
354
+ case "desktop":
355
+ process.stdout.write(`${runDesktopCommand(command.action)}\n`);
356
+ break;
285
357
  case "service":
286
358
  process.stdout.write(`${await runServiceCommand(command.action)}\n`);
287
359
  break;
@@ -303,6 +375,9 @@ try {
303
375
  case "internal-console-run":
304
376
  await runLocalConsoleProcess();
305
377
  break;
378
+ case "internal-desktop-open":
379
+ await runDesktopOpenCommand(command.url);
380
+ break;
306
381
  case "ps":
307
382
  if (command.manage) {
308
383
  await runPsManageCommand({ all: command.all });
@@ -1,4 +1,4 @@
1
- export type LaunchErrorCode = "unsafe_project_root" | "unsupported_tutti_schema" | "project_identity_conflict" | "git_bootstrap_failed" | "sensitive_file_detected" | "relay_unavailable" | "relay_registration_failed" | "host_not_running" | "shutdown_confirmation_required" | "shutdown_rejected" | "archive_confirmation_required" | "archive_rejected" | "forget_confirmation_required" | "forget_rejected" | "project_target_ambiguous" | "project_target_not_found" | "provider_configuration_required" | "provider_setup_cancelled" | "provider_validation_failed" | "store_migration_failed" | "existing_server_unhealthy" | "takeover_rejected" | "takeover_failed" | "port_unavailable";
1
+ export type LaunchErrorCode = "unsafe_project_root" | "unsupported_tutti_schema" | "project_identity_conflict" | "git_bootstrap_failed" | "sensitive_file_detected" | "relay_unavailable" | "relay_registration_failed" | "host_not_running" | "shutdown_confirmation_required" | "shutdown_rejected" | "archive_confirmation_required" | "archive_rejected" | "forget_confirmation_required" | "forget_rejected" | "project_target_ambiguous" | "project_target_not_found" | "provider_configuration_required" | "provider_setup_cancelled" | "provider_validation_failed" | "store_migration_failed" | "existing_server_unhealthy" | "takeover_rejected" | "takeover_failed" | "port_unavailable" | "desktop_integration_unavailable" | "desktop_protocol_invalid" | "desktop_open_failed";
2
2
  export declare class LaunchError extends Error {
3
3
  readonly code: LaunchErrorCode;
4
4
  readonly next: string;
@@ -1,2 +1,8 @@
1
+ export type CliPackageIdentity = {
2
+ name: string;
3
+ version: string;
4
+ root: string;
5
+ };
6
+ export declare function readCliPackageIdentity(): CliPackageIdentity;
1
7
  export declare function readCliVersion(): string;
2
8
  //# sourceMappingURL=version.d.ts.map
@@ -1,7 +1,16 @@
1
1
  import { readFileSync } from "node:fs";
2
- export function readCliVersion() {
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export function readCliPackageIdentity() {
3
5
  const packageUrl = new URL("../../../package.json", import.meta.url);
4
6
  const parsed = JSON.parse(readFileSync(packageUrl, "utf8"));
5
- return typeof parsed.version === "string" ? parsed.version : "0.0.0";
7
+ return {
8
+ name: typeof parsed.name === "string" ? parsed.name : "unknown",
9
+ version: typeof parsed.version === "string" ? parsed.version : "0.0.0",
10
+ root: resolve(dirname(fileURLToPath(packageUrl))),
11
+ };
12
+ }
13
+ export function readCliVersion() {
14
+ return readCliPackageIdentity().version;
6
15
  }
7
16
  //# sourceMappingURL=version.js.map
@@ -0,0 +1,5 @@
1
+ export * from "./manager.js";
2
+ export * from "./platform.js";
3
+ export * from "./protocol.js";
4
+ export * from "./record.js";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ export * from "./manager.js";
2
+ export * from "./platform.js";
3
+ export * from "./protocol.js";
4
+ export * from "./record.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,12 @@
1
+ import { type DesktopCommandRunner, type DesktopInstallationIdentity, type DesktopPlatformAdapter } from "./platform.js";
2
+ export declare function findDesktopExecutable(name: string, env: NodeJS.ProcessEnv): string | null;
3
+ export declare function quoteDesktopExecArgument(value: string): string;
4
+ export declare function renderLinuxDesktopEntry(identity: DesktopInstallationIdentity, iconPath: string): string;
5
+ export declare function createLinuxDesktopAdapter(options: {
6
+ homeDir: string;
7
+ assetRoot: string;
8
+ env?: NodeJS.ProcessEnv;
9
+ runner?: DesktopCommandRunner;
10
+ }): DesktopPlatformAdapter;
11
+ export declare function linuxDesktopRequirementsAvailable(assetRoot: string, env: NodeJS.ProcessEnv): boolean;
12
+ //# sourceMappingURL=linux.d.ts.map
@@ -0,0 +1,186 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { delimiter, dirname, isAbsolute, join } from "node:path";
3
+ import { requireDesktopCommand, runDesktopCommand, } from "./platform.js";
4
+ const DESKTOP_FILE_NAME = "now.tutti.Tutti.desktop";
5
+ const DESKTOP_MIME = "x-scheme-handler/tutti";
6
+ export function findDesktopExecutable(name, env) {
7
+ for (const directory of (env.PATH ?? "").split(delimiter)) {
8
+ if (directory.trim() === "") {
9
+ continue;
10
+ }
11
+ const candidate = join(directory, name);
12
+ if (existsSync(candidate)) {
13
+ return candidate;
14
+ }
15
+ }
16
+ return null;
17
+ }
18
+ export function quoteDesktopExecArgument(value) {
19
+ if (value.includes("\n") || value.includes("\r")) {
20
+ throw new Error("Desktop executable paths cannot contain line breaks.");
21
+ }
22
+ const escaped = value.replaceAll("%", "%%").replace(/([\\"`$])/gu, "\\$1");
23
+ return `"${escaped}"`;
24
+ }
25
+ export function renderLinuxDesktopEntry(identity, iconPath) {
26
+ return [
27
+ "[Desktop Entry]",
28
+ "Type=Application",
29
+ "Version=1.0",
30
+ "Name=Tutti",
31
+ "Comment=Open the Tutti local project manager",
32
+ `Exec=${quoteDesktopExecArgument(identity.nodeExecutable)} ${quoteDesktopExecArgument(identity.cliEntrypoint)} internal desktop-open %u`,
33
+ `TryExec=${identity.nodeExecutable}`,
34
+ `Icon=${iconPath}`,
35
+ "Terminal=false",
36
+ "NoDisplay=false",
37
+ "StartupNotify=false",
38
+ "Categories=Development;",
39
+ `MimeType=${DESKTOP_MIME};`,
40
+ "X-Tutti-Managed=true",
41
+ "X-Tutti-Schema-Version=1",
42
+ "",
43
+ ].join("\n");
44
+ }
45
+ function writeAtomic(path, contents, mode) {
46
+ const directory = dirname(path);
47
+ mkdirSync(directory, { recursive: true });
48
+ const temporaryPath = join(directory, `.tutti-${process.pid}-${Date.now()}.tmp`);
49
+ try {
50
+ writeFileSync(temporaryPath, contents, { mode });
51
+ chmodSync(temporaryPath, mode);
52
+ renameSync(temporaryPath, path);
53
+ chmodSync(path, mode);
54
+ }
55
+ finally {
56
+ rmSync(temporaryPath, { force: true });
57
+ }
58
+ }
59
+ function removeOwnedAssociation(path) {
60
+ if (!existsSync(path)) {
61
+ return;
62
+ }
63
+ const original = readFileSync(path, "utf8");
64
+ let section = "";
65
+ const lines = original.split(/\r?\n/u);
66
+ const output = [];
67
+ for (const line of lines) {
68
+ const sectionMatch = /^\s*\[([^\]]+)\]\s*$/u.exec(line);
69
+ if (sectionMatch !== null) {
70
+ section = sectionMatch[1] ?? "";
71
+ output.push(line);
72
+ continue;
73
+ }
74
+ if ((section === "Default Applications" || section === "Added Associations") &&
75
+ line.startsWith(`${DESKTOP_MIME}=`)) {
76
+ const handlers = line
77
+ .slice(DESKTOP_MIME.length + 1)
78
+ .split(";")
79
+ .filter((handler) => handler !== "" && handler !== DESKTOP_FILE_NAME);
80
+ if (handlers.length > 0) {
81
+ output.push(`${DESKTOP_MIME}=${handlers.join(";")};`);
82
+ }
83
+ continue;
84
+ }
85
+ output.push(line);
86
+ }
87
+ const updated = output.join("\n");
88
+ if (updated !== original) {
89
+ writeAtomic(path, updated, 0o600);
90
+ }
91
+ }
92
+ export function createLinuxDesktopAdapter(options) {
93
+ const env = options.env ?? process.env;
94
+ const runner = options.runner ?? runDesktopCommand;
95
+ const dataHome = env.XDG_DATA_HOME !== undefined && isAbsolute(env.XDG_DATA_HOME)
96
+ ? env.XDG_DATA_HOME
97
+ : join(options.homeDir, ".local", "share");
98
+ const configHome = env.XDG_CONFIG_HOME !== undefined && isAbsolute(env.XDG_CONFIG_HOME)
99
+ ? env.XDG_CONFIG_HOME
100
+ : join(options.homeDir, ".config");
101
+ const applicationsDir = join(dataHome, "applications");
102
+ const entryPath = join(applicationsDir, DESKTOP_FILE_NAME);
103
+ const iconPath = join(dataHome, "icons", "hicolor", "512x512", "apps", "now.tutti.Tutti.png");
104
+ const assetIconPath = join(options.assetRoot, "Tutti-square.png");
105
+ const xdgMime = findDesktopExecutable("xdg-mime", env);
106
+ const updateDesktopDatabase = findDesktopExecutable("update-desktop-database", env);
107
+ const inspect = () => {
108
+ if (!existsSync(entryPath)) {
109
+ return "missing";
110
+ }
111
+ const contents = readFileSync(entryPath, "utf8");
112
+ return contents.includes("X-Tutti-Managed=true") &&
113
+ contents.includes("X-Tutti-Schema-Version=1")
114
+ ? "owned"
115
+ : "conflict";
116
+ };
117
+ return {
118
+ platform: "linux",
119
+ entryPath,
120
+ inspect,
121
+ install(identity) {
122
+ if (inspect() === "conflict") {
123
+ throw new Error("A desktop entry not managed by Tutti already occupies the target path.");
124
+ }
125
+ if (xdgMime === null || !existsSync(assetIconPath)) {
126
+ throw new Error("The Linux desktop registration tools or packaged icon are unavailable.");
127
+ }
128
+ const previousEntry = existsSync(entryPath) ? readFileSync(entryPath) : null;
129
+ const previousIcon = existsSync(iconPath) ? readFileSync(iconPath) : null;
130
+ try {
131
+ writeAtomic(entryPath, renderLinuxDesktopEntry(identity, iconPath), 0o644);
132
+ mkdirSync(dirname(iconPath), { recursive: true });
133
+ copyFileSync(assetIconPath, iconPath);
134
+ chmodSync(iconPath, 0o644);
135
+ requireDesktopCommand(runner, xdgMime, ["default", DESKTOP_FILE_NAME, DESKTOP_MIME], "Linux URL scheme registration");
136
+ if (updateDesktopDatabase !== null) {
137
+ runner(updateDesktopDatabase, [applicationsDir]);
138
+ }
139
+ }
140
+ catch (error) {
141
+ if (previousEntry === null) {
142
+ rmSync(entryPath, { force: true });
143
+ }
144
+ else {
145
+ writeAtomic(entryPath, previousEntry, 0o644);
146
+ }
147
+ if (previousIcon === null) {
148
+ rmSync(iconPath, { force: true });
149
+ }
150
+ else {
151
+ writeAtomic(iconPath, previousIcon, 0o644);
152
+ }
153
+ throw error;
154
+ }
155
+ },
156
+ remove() {
157
+ const inspection = inspect();
158
+ if (inspection === "conflict") {
159
+ throw new Error("The existing Linux desktop entry is not managed by Tutti.");
160
+ }
161
+ if (inspection === "owned") {
162
+ rmSync(entryPath, { force: true });
163
+ rmSync(iconPath, { force: true });
164
+ }
165
+ else if (existsSync(iconPath) &&
166
+ existsSync(assetIconPath) &&
167
+ readFileSync(iconPath).equals(readFileSync(assetIconPath))) {
168
+ rmSync(iconPath, { force: true });
169
+ }
170
+ for (const mimeappsPath of new Set([
171
+ join(configHome, "mimeapps.list"),
172
+ join(applicationsDir, "mimeapps.list"),
173
+ ])) {
174
+ removeOwnedAssociation(mimeappsPath);
175
+ }
176
+ if (updateDesktopDatabase !== null) {
177
+ runner(updateDesktopDatabase, [applicationsDir]);
178
+ }
179
+ },
180
+ };
181
+ }
182
+ export function linuxDesktopRequirementsAvailable(assetRoot, env) {
183
+ return (findDesktopExecutable("xdg-mime", env) !== null &&
184
+ existsSync(join(assetRoot, "Tutti-square.png")));
185
+ }
186
+ //# sourceMappingURL=linux.js.map
@@ -0,0 +1,9 @@
1
+ import { type DesktopCommandRunner, type DesktopInstallationIdentity, type DesktopPlatformAdapter } from "./platform.js";
2
+ export declare function renderMacosHandlerTemplate(template: string, identity: DesktopInstallationIdentity): string;
3
+ export declare function createMacosDesktopAdapter(options: {
4
+ homeDir: string;
5
+ assetRoot: string;
6
+ runner?: DesktopCommandRunner;
7
+ }): DesktopPlatformAdapter;
8
+ export declare function macosDesktopRequirementsAvailable(assetRoot: string): boolean;
9
+ //# sourceMappingURL=macos.d.ts.map