openclacky 1.5.21 → 1.5.22

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/lib/clacky/billing/platform_billing.rb +3 -0
  4. data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
  5. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  6. data/lib/clacky/providers.rb +43 -17
  7. data/lib/clacky/search_config.rb +33 -11
  8. data/lib/clacky/server/git_panel.rb +360 -34
  9. data/lib/clacky/server/http_server.rb +145 -47
  10. data/lib/clacky/server/preview.rb +118 -17
  11. data/lib/clacky/server/scheduler.rb +81 -2
  12. data/lib/clacky/server/web_ui_controller.rb +35 -27
  13. data/lib/clacky/session_manager.rb +52 -5
  14. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  15. data/lib/clacky/utils/model_pricing.rb +60 -2
  16. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  17. data/lib/clacky/version.rb +1 -1
  18. data/lib/clacky/web/app.css +650 -96
  19. data/lib/clacky/web/app.js +59 -6
  20. data/lib/clacky/web/auth.js +1 -1
  21. data/lib/clacky/web/components/composer.js +107 -8
  22. data/lib/clacky/web/components/image-zoom.js +165 -0
  23. data/lib/clacky/web/components/model-picker.js +6 -4
  24. data/lib/clacky/web/components/notify.js +2 -2
  25. data/lib/clacky/web/core/aside.js +60 -3
  26. data/lib/clacky/web/features/billing/view.js +2 -2
  27. data/lib/clacky/web/features/channels/view.js +5 -2
  28. data/lib/clacky/web/features/extensions/view.js +2 -2
  29. data/lib/clacky/web/features/new-session/store.js +79 -2
  30. data/lib/clacky/web/features/new-session/view.js +496 -58
  31. data/lib/clacky/web/features/skills/store.js +1 -1
  32. data/lib/clacky/web/features/tasks/view.js +162 -51
  33. data/lib/clacky/web/features/workspace/store.js +13 -6
  34. data/lib/clacky/web/features/workspace/view.js +153 -163
  35. data/lib/clacky/web/i18n.js +238 -56
  36. data/lib/clacky/web/index.html +173 -157
  37. data/lib/clacky/web/projects.js +37 -4
  38. data/lib/clacky/web/sessions.js +181 -105
  39. data/lib/clacky/web/settings.js +26 -8
  40. data/lib/clacky/web/skills.js +2 -2
  41. data/lib/clacky/web/theme.js +17 -2
  42. data/lib/clacky/web/utils.js +17 -2
  43. data/lib/clacky/web/weixin-qr.html +2 -1
  44. data/lib/clacky/web/ws-dispatcher.js +8 -29
  45. metadata +2 -1
@@ -75,26 +75,38 @@ module Clacky
75
75
  ev[:skill_command_display] = skill_command_display if skill_command_display
76
76
  ev[:editable] = false unless editable
77
77
  ev[:references] = references unless Array(references).empty?
78
- rendered = Array(files).filter_map do |f|
78
+ rendered_images = []
79
+ rendered_attachments = []
80
+ Array(files).each do |f|
81
+ next if f[:reference] || f["reference"]
82
+
79
83
  url = f[:data_url] || f["data_url"]
80
84
  name = f[:name] || f["name"]
81
85
  path = f[:path] || f["path"]
82
86
  type = f[:type] || f["type"] || ""
87
+ mime = f[:mime_type] || f["mime_type"]
88
+ image = type.to_s == "image" || mime.to_s.start_with?("image/")
83
89
 
84
90
  if url
85
- url
86
- elsif type.to_s == "image" && path && File.exist?(path.to_s)
91
+ rendered_images << url
92
+ elsif image && path && File.exist?(path.to_s)
87
93
  # Serve via the /api/local-image proxy instead of inlining a base64
88
94
  # data URL. Inlining forced a synchronous disk-read + full base64
89
95
  # encode + downscale on every history replay (2-3s lag for sessions
90
96
  # with downgraded text-model images). The proxy lets the browser
91
97
  # lazy-load + cache the image, keeping the replay response tiny.
92
- "/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
98
+ rendered_images << "/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
99
+ elsif image && name
100
+ rendered_images << "expired:#{name}"
93
101
  elsif name
94
- type.to_s == "image" ? "expired:#{name}" : "pdf:#{name}"
102
+ attachment_type = type.to_s.empty? ? (mime || "file") : type
103
+ attachment = { name: name, type: attachment_type }
104
+ attachment[:path] = path if path && File.exist?(path.to_s)
105
+ rendered_attachments << attachment
95
106
  end
96
107
  end
97
- ev[:images] = rendered unless rendered.empty?
108
+ ev[:images] = rendered_images unless rendered_images.empty?
109
+ ev[:attachments] = rendered_attachments unless rendered_attachments.empty?
98
110
  @events << ev
99
111
  end
100
112
 
@@ -838,9 +850,6 @@ module Clacky
838
850
  session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
839
851
  action = path[%r{/git/([a-z]+)$}, 1]
840
852
  api_session_git(session_id, action, req, res)
841
- elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
842
- session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
843
- api_session_git_commit(session_id, req, res)
844
853
  elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/restore$})
845
854
  session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
846
855
  api_session_git_restore(session_id, req, res)
@@ -960,6 +969,12 @@ module Clacky
960
969
  elsif method == "DELETE" && path.match?(%r{^/api/memories/[^/]+$})
961
970
  filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
962
971
  api_memories_delete(filename, res)
972
+ elsif method == "GET" && path.match?(%r{^/api/projects/[^/]+/git$})
973
+ project_id = path.sub("/api/projects/", "").sub("/git", "")
974
+ api_project_git(project_id, req, res)
975
+ elsif method == "POST" && path.match?(%r{^/api/projects/[^/]+/git/checkout$})
976
+ project_id = path.sub("/api/projects/", "").sub("/git/checkout", "")
977
+ api_project_git_checkout(project_id, req, res)
963
978
  elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
964
979
  project_id = path.sub("/api/projects/", "")
965
980
  api_update_project(project_id, req, res)
@@ -2321,7 +2336,7 @@ module Clacky
2321
2336
  json_response(res, 200, {
2322
2337
  ok: true,
2323
2338
  provider: config["provider"],
2324
- key_masked: config["key"].empty? ? nil : mask_api_key(config["key"]),
2339
+ keys_masked: masked_search_keys(config),
2325
2340
  available: Clacky::Utils::SearcherManager.available
2326
2341
  })
2327
2342
  end
@@ -2338,12 +2353,12 @@ module Clacky
2338
2353
  return json_response(res, 422, { error: "Unknown search provider: #{provider}" })
2339
2354
  end
2340
2355
 
2341
- key = body["key"].to_s
2342
- key = Clacky::SearchConfig.key if key.include?("****")
2343
-
2344
2356
  current = Clacky::SearchConfig.load
2345
2357
  final_provider = body.key?("provider") ? provider : current["provider"]
2346
- final_key = body.key?("key") ? key : current["key"]
2358
+ # A masked value only ever stands for the same provider's stored key.
2359
+ key = body["key"].to_s
2360
+ key = Clacky::SearchConfig.key_for(final_provider) if key.include?("****")
2361
+ final_key = body.key?("key") ? key : Clacky::SearchConfig.key_for(final_provider)
2347
2362
 
2348
2363
  # deepseek_web falls back to the configured model key, so it may be
2349
2364
  # saved without one. Every other searcher is a script that reads the
@@ -2361,11 +2376,15 @@ module Clacky
2361
2376
  key: body.key?("key") ? key : nil
2362
2377
  )
2363
2378
  config = Clacky::SearchConfig.load
2364
- json_response(res, 200, { ok: true, provider: config["provider"] })
2379
+ json_response(res, 200, { ok: true, provider: config["provider"], keys_masked: masked_search_keys(config) })
2365
2380
  rescue => e
2366
2381
  json_response(res, 422, { error: e.message })
2367
2382
  end
2368
2383
 
2384
+ private def masked_search_keys(config)
2385
+ config["keys"].transform_values { |key| mask_api_key(key) }
2386
+ end
2387
+
2369
2388
  private def search_provider_known?(provider)
2370
2389
  return true if provider.empty?
2371
2390
  return true if provider == Clacky::SearchConfig::DEEPSEEK_WEB
@@ -2382,7 +2401,7 @@ module Clacky
2382
2401
  return json_response(res, 200, { ok: false, message: "provider is required" }) if provider.empty?
2383
2402
 
2384
2403
  key = body["key"].to_s
2385
- key = Clacky::SearchConfig.key if key.empty? || key.include?("****")
2404
+ key = Clacky::SearchConfig.key_for(provider) if key.empty? || key.include?("****")
2386
2405
  return test_deepseek_web_search(res, key) if provider == Clacky::SearchConfig::DEEPSEEK_WEB
2387
2406
 
2388
2407
  script = Clacky::Utils::SearcherManager.path_for(provider)
@@ -4590,10 +4609,12 @@ module Clacky
4590
4609
 
4591
4610
  path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
4592
4611
  ext = File.extname(path).downcase.sub(/\A\./, "")
4612
+ # Extension-less files (Dockerfile, Makefile, Gemfile) declare no
4613
+ # document type, so no app lists them. Plain text is the closest
4614
+ # approximation and keeps the picker useful.
4615
+ ext = "txt" if ext.empty?
4593
4616
 
4594
- apps = if ext.empty?
4595
- []
4596
- elsif Utils::EnvironmentDetector.os_type == :wsl
4617
+ apps = if Utils::EnvironmentDetector.os_type == :wsl
4597
4618
  Utils::WindowsAppDetector.apps_for_ext(ext)
4598
4619
  else
4599
4620
  Utils::MacAppDetector.apps_for_ext(ext)
@@ -4610,9 +4631,7 @@ module Clacky
4610
4631
  return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
4611
4632
 
4612
4633
  path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
4613
- app = if File.extname(path).empty?
4614
- nil
4615
- elsif Utils::EnvironmentDetector.os_type == :wsl
4634
+ app = if Utils::EnvironmentDetector.os_type == :wsl
4616
4635
  Utils::WindowsAppDetector.default_app_for(path)
4617
4636
  else
4618
4637
  Utils::MacAppDetector.default_app_for(path)
@@ -4682,6 +4701,9 @@ module Clacky
4682
4701
  when "open"
4683
4702
  result = Utils::EnvironmentDetector.open_file(linux_path)
4684
4703
  return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
4704
+ # system() returns false when the launcher exits non-zero (broken app
4705
+ # association, no handler). Surface it instead of a silent 200.
4706
+ return json_response(res, 500, { error: "failed to open file" }) unless result
4685
4707
  json_response(res, 200, { ok: true })
4686
4708
  when "open_with"
4687
4709
  result = if Utils::EnvironmentDetector.os_type == :wsl
@@ -5414,9 +5436,22 @@ module Clacky
5414
5436
  when "status"
5415
5437
  json_response(res, 200, { repo: true }.merge(Clacky::Server::GitPanel.status(dir)))
5416
5438
  when "diff"
5417
- json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(dir, file: query["file"]) })
5439
+ json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(
5440
+ dir, file: query["file"], context: query["context"],
5441
+ scope: query["scope"], base: query["base"]
5442
+ ) })
5443
+ when "review"
5444
+ if query["scope"].to_s == "lastTurn"
5445
+ json_response(res, 200, Clacky::Server::GitPanel.last_turn_review(git_session_agent(session_id)))
5446
+ else
5447
+ json_response(res, 200, Clacky::Server::GitPanel.review(
5448
+ dir, scope: query["scope"], base: query["base"]
5449
+ ))
5450
+ end
5418
5451
  when "log"
5419
- json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(dir, limit: query["limit"] || 50) })
5452
+ json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(
5453
+ dir, limit: query["limit"] || 50, unpushed: query["unpushed"].to_s == "1"
5454
+ ) })
5420
5455
  when "branches"
5421
5456
  json_response(res, 200, { repo: true, branches: Clacky::Server::GitPanel.branches(dir) })
5422
5457
  else
@@ -5424,24 +5459,6 @@ module Clacky
5424
5459
  end
5425
5460
  end
5426
5461
 
5427
- # POST /api/sessions/:id/git/commit — body: { message:, files: [..] }.
5428
- def api_session_git_commit(session_id, req, res)
5429
- dir = git_session_dir(session_id, res)
5430
- return unless dir
5431
-
5432
- unless Clacky::Server::GitPanel.repo?(dir)
5433
- return json_response(res, 400, { error: "Not a git repository" })
5434
- end
5435
-
5436
- body = parse_json_body(req)
5437
- result = Clacky::Server::GitPanel.commit(dir, message: body["message"], files: body["files"])
5438
- if result[:ok]
5439
- json_response(res, 200, result)
5440
- else
5441
- json_response(res, 422, { error: result[:error] })
5442
- end
5443
- end
5444
-
5445
5462
  # POST /api/sessions/:id/git/restore - body: { file: }.
5446
5463
  # Discards uncommitted changes to one file, back to its HEAD state.
5447
5464
  def api_session_git_restore(session_id, req, res)
@@ -5545,8 +5562,7 @@ module Clacky
5545
5562
  json_response(res, 404, { error: "Session not found" })
5546
5563
  return nil
5547
5564
  end
5548
- session = @registry.get(session_id)
5549
- agent = session && session[:agent]
5565
+ agent = git_session_agent(session_id)
5550
5566
  unless agent
5551
5567
  json_response(res, 404, { error: "Session not found" })
5552
5568
  return nil
@@ -5558,6 +5574,11 @@ module Clacky
5558
5574
  end
5559
5575
  dir
5560
5576
  end
5577
+
5578
+ private def git_session_agent(session_id)
5579
+ session = @registry.get(session_id)
5580
+ session && session[:agent]
5581
+ end
5561
5582
  # Lists one directory level inside the session's working_dir (lazy, per-layer).
5562
5583
  # Path traversal outside working_dir is rejected. Noisy dirs are hidden.
5563
5584
  IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
@@ -5571,7 +5592,11 @@ module Clacky
5571
5592
  return json_response(res, 404, { error: "Session not found" }) unless agent
5572
5593
 
5573
5594
  root = File.expand_path(agent.working_dir.to_s)
5574
- return json_response(res, 404, { error: "Working directory not found" }) unless Dir.exist?(root)
5595
+ unless Dir.exist?(root)
5596
+ # `code` lets the client tell "the folder was deleted" apart from any
5597
+ # other failure and explain it, instead of showing a generic error.
5598
+ return json_response(res, 404, { error: "Working directory not found", code: "working_dir_missing", path: root })
5599
+ end
5575
5600
 
5576
5601
  query = URI.decode_www_form(req.query_string.to_s).to_h
5577
5602
  rel = query["path"].to_s
@@ -5596,7 +5621,9 @@ module Clacky
5596
5621
  end
5597
5622
  end
5598
5623
 
5599
- return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
5624
+ unless Dir.exist?(target)
5625
+ return json_response(res, 404, { error: "Directory not found", code: "directory_missing" })
5626
+ end
5600
5627
  entries = Dir.children(target).reject do |name|
5601
5628
  IGNORED_FILE_ENTRIES.include?(name) || (!show_hidden && name.start_with?("."))
5602
5629
  end
@@ -7280,6 +7307,71 @@ module Clacky
7280
7307
  json_response(res, 500, { error: e.message })
7281
7308
  end
7282
7309
 
7310
+ # GET /api/projects/:id/git?dir= — branch state of a work tree, used by
7311
+ # the new-session branch picker. `dir` overrides the project's own
7312
+ # working dir, since a session may run in a different repo. `repo: false`
7313
+ # covers a missing working dir, a missing directory, and plain non-git
7314
+ # folders, so the picker can simply hide itself.
7315
+ def api_project_git(project_id, req, res)
7316
+ project = @project_manager.find(project_id)
7317
+ return json_response(res, 404, { error: "Project not found" }) unless project
7318
+
7319
+ dir = git_work_tree(project, git_dir_param(req))
7320
+ return json_response(res, 200, { repo: false }) unless dir
7321
+
7322
+ json_response(res, 200, {
7323
+ repo: true,
7324
+ branch: Clacky::Server::GitPanel.branch_name(dir),
7325
+ branches: Clacky::Server::GitPanel.branches(dir),
7326
+ })
7327
+ end
7328
+
7329
+ # POST /api/projects/:id/git/checkout — body: { branch:, create:?, from:?, dir:? }
7330
+ # Switches a work tree. `create: true` branches off `from`, which may be
7331
+ # a remote ref (origin/feature) to get a tracking branch.
7332
+ def api_project_git_checkout(project_id, req, res)
7333
+ project = @project_manager.find(project_id)
7334
+ return json_response(res, 404, { error: "Project not found" }) unless project
7335
+
7336
+ body = parse_json_body(req)
7337
+ dir = git_work_tree(project, body["dir"])
7338
+ return json_response(res, 400, { error: "Not a git repository" }) unless dir
7339
+
7340
+ result = Clacky::Server::GitPanel.checkout(
7341
+ dir,
7342
+ branch: body["branch"],
7343
+ create: body["create"] == true,
7344
+ from: body["from"]
7345
+ )
7346
+
7347
+ unless result[:ok]
7348
+ return json_response(res, 422, { error: result[:error] })
7349
+ end
7350
+
7351
+ json_response(res, 200, {
7352
+ ok: true,
7353
+ branch: Clacky::Server::GitPanel.branch_name(dir),
7354
+ branches: Clacky::Server::GitPanel.branches(dir),
7355
+ })
7356
+ end
7357
+
7358
+ def git_dir_param(req)
7359
+ URI.decode_www_form(req.query_string.to_s).to_h["dir"]
7360
+ rescue ArgumentError
7361
+ nil
7362
+ end
7363
+
7364
+ # Resolves the work tree a git request targets: the explicit `dir` when
7365
+ # given, otherwise the project's own working dir. Nil when the path is
7366
+ # not an existing git repository.
7367
+ def git_work_tree(project, dir_param)
7368
+ raw = dir_param.to_s.strip
7369
+ dir = raw.empty? ? project[:working_dir].to_s : File.expand_path(raw)
7370
+ return nil if dir.empty? || !Dir.exist?(dir)
7371
+ return nil unless Clacky::Server::GitPanel.repo?(dir)
7372
+ dir
7373
+ end
7374
+
7283
7375
  # PATCH /api/projects/:id - update a project
7284
7376
  # Body: { name:?, description:?, color:?, working_dir:?, pinned:? } - only provided fields are changed
7285
7377
  def api_update_project(project_id, req, res)
@@ -8403,6 +8495,7 @@ module Clacky
8403
8495
  Thread.current[:task_epoch] = epoch
8404
8496
  run_result = task.call
8405
8497
  awaiting = false
8498
+ web_ui = nil
8406
8499
  owns_epoch = true
8407
8500
  loop do
8408
8501
  owns_epoch = @registry.current_epoch(session_id).to_i == epoch.to_i
@@ -8412,6 +8505,7 @@ module Clacky
8412
8505
  @registry.with_session(session_id) do |s|
8413
8506
  owns_epoch = s[:epoch].to_i == epoch.to_i
8414
8507
  next unless owns_epoch
8508
+ web_ui = s[:ui]
8415
8509
  awaiting = run_result.is_a?(Hash) && run_result[:awaiting_user_feedback]
8416
8510
  pending = agent.take_pending_input if Agent.task_completed?(run_result)
8417
8511
  unless pending
@@ -8430,7 +8524,11 @@ module Clacky
8430
8524
  # session history: a chime is a live cue, never replayed on refresh.
8431
8525
  # Also fires when awaiting feedback — that is precisely when the user
8432
8526
  # most needs pulling back; the flag lets the client word it differently.
8433
- broadcast_all(type: "task_finished", session_id: session_id, awaiting_feedback: !!awaiting)
8527
+ finished = { type: "task_finished", session_id: session_id, awaiting_feedback: !!awaiting }
8528
+ # Lets clients not viewing this session keep the summary until it is first opened.
8529
+ complete = web_ui&.last_complete
8530
+ finished[:complete] = complete if complete&.dig(:task_id) && run_result.is_a?(Hash) && complete[:task_id] == run_result[:task_id]
8531
+ broadcast_all(finished)
8434
8532
  @session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
8435
8533
  # Start idle compression timer now that the agent is idle
8436
8534
  idle_timer&.start
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "socket"
5
5
  require "timeout"
6
+ require "uri"
6
7
 
7
8
  module Clacky
8
9
  module Server
@@ -47,18 +48,53 @@ module Clacky
47
48
  ".ogg" => "audio/ogg"
48
49
  }.freeze
49
50
 
50
- # Injected into preview HTML responses. The preview iframe is sandboxed
51
- # without allow-same-origin, so the host page cannot read its location
52
- # (and the iframe's src property does not follow internal navigations).
53
- # Each served page therefore announces its own URL to the parent.
54
- PREVIEW_NAV_REPORT = '<script>(function(){try{parent.postMessage({__clackyPreview:1,href:location.href},"*")}catch(e){}})()</script>'
51
+ # Injected into preview HTML responses. The preview iframe is cross-origin
52
+ # to the host page, so the host cannot read its location (and the iframe's
53
+ # src property does not follow internal navigations). Each served page
54
+ # therefore announces its own URL to the parent. Re-reporting is not just
55
+ # the initial load: Turbo/htmx/React-Router navigations swap the DOM
56
+ # without a full page load, so the same script also listens for their
57
+ # events, patches the History API and falls back to a slow poll.
58
+ PREVIEW_NAV_REPORT = <<~JS.freeze
59
+ <script>(function(){
60
+ var last = null;
61
+ function report(){
62
+ try {
63
+ if (location.href === last) return;
64
+ last = location.href;
65
+ parent.postMessage({__clackyPreview:1,href:location.href},"*");
66
+ } catch(e){}
67
+ }
68
+ report();
69
+ ["turbo:load","turbo:render","turbo:frame-load","htmx:afterSwap","pjax:end","DOMContentLoaded"].forEach(function(n){
70
+ document.addEventListener(n, report);
71
+ });
72
+ window.addEventListener("popstate", report);
73
+ window.addEventListener("hashchange", report);
74
+ var push = history.pushState, replace = history.replaceState;
75
+ history.pushState = function(){ push.apply(history, arguments); report(); };
76
+ history.replaceState = function(){ replace.apply(history, arguments); report(); };
77
+ setInterval(report, 500);
78
+ })()</script>
79
+ JS
80
+
81
+
82
+ # Must match ISOLATED_PREVIEW_HOST in
83
+ # default_extensions/preview/panels/preview/view.js. Chrome resolves
84
+ # every *.localhost name to loopback, so the preview frame runs on this
85
+ # origin while the UI stays on 127.0.0.1/localhost.
86
+ ISOLATED_PREVIEW_HOST = "preview.localhost"
87
+
88
+ # Hostnames a dev server may redirect to with an absolute URL. Only these
89
+ # are folded back into the proxy prefix; anything else is a deliberate
90
+ # off-box redirect and is passed through untouched.
91
+ LOOPBACK_HOSTS = %w[127.0.0.1 localhost ::1].freeze
55
92
 
56
93
  # Headers dropped when proxying a localhost dev server through the
57
- # preview panel. X-Frame-Options is what blocks the sandboxed iframe;
58
- # hop-by-hop headers and Set-Cookie must not cross the proxy boundary
59
- # (WEBrick recomputes the former, cookies would leak the upstream's
60
- # domain into ours). CSP frame-ancestors is handled separately so the
61
- # rest of the policy survives.
94
+ # preview panel. X-Frame-Options is what blocks the sandboxed iframe and
95
+ # hop-by-hop headers are recomputed by WEBrick. Set-Cookie is handled
96
+ # separately (see #preview_cookie) because the isolated preview origin
97
+ # needs the upstream session to survive inside a third-party frame.
62
98
  PROXY_SKIP_HEADERS = %w[
63
99
  x-frame-options
64
100
  transfer-encoding
@@ -70,7 +106,6 @@ module Clacky
70
106
  te
71
107
  trailer
72
108
  upgrade
73
- set-cookie
74
109
  ].freeze
75
110
 
76
111
  # Connection-stage failures when dialing the localhost dev server. The
@@ -189,11 +224,8 @@ module Clacky
189
224
  # the document start) so the reporter runs before page scripts while a
190
225
  # leading <!DOCTYPE> stays first — prepending would flip the document
191
226
  # into quirks mode.
192
- private def preview_body(abs)
193
- body = File.binread(abs)
194
- return body unless (PREVIEW_CONTENT_TYPES[File.extname(abs).downcase] || "").start_with?("text/html")
195
-
196
- html = body.force_encoding(Encoding::UTF_8)
227
+ private def inject_nav_report(html)
228
+ html = html.force_encoding(Encoding::UTF_8)
197
229
  if (m = html.match(/<head[^>]*>/i))
198
230
  html[m.end(0), 0] = PREVIEW_NAV_REPORT
199
231
  elsif (m = html.match(/<html[^>]*>/i))
@@ -206,6 +238,13 @@ module Clacky
206
238
  html
207
239
  end
208
240
 
241
+ private def preview_body(abs)
242
+ body = File.binread(abs)
243
+ return body unless (PREVIEW_CONTENT_TYPES[File.extname(abs).downcase] || "").start_with?("text/html")
244
+
245
+ inject_nav_report(body)
246
+ end
247
+
209
248
  # Decode %XX escapes in a preview path segment without treating "+" as
210
249
  # space: "+" is a legal literal character in file names.
211
250
  private def preview_decode_path(seg)
@@ -285,6 +324,15 @@ module Clacky
285
324
  next if value.nil? || value.empty?
286
325
  end
287
326
  next if PROXY_SKIP_HEADERS.include?(down)
327
+ if down == "set-cookie"
328
+ cookie = preview_cookie(value)
329
+ res.cookies << cookie if cookie && isolated_preview_request?(req)
330
+ next
331
+ end
332
+ if down == "location"
333
+ res[name] = rewrite_preview_location(value, port, req)
334
+ next
335
+ end
288
336
  if down == "content-type"
289
337
  is_html = value.start_with?("text/html")
290
338
  is_css = value.start_with?("text/css")
@@ -300,7 +348,7 @@ module Clacky
300
348
  if req.request_method != "HEAD"
301
349
  body = resp.body.to_s
302
350
  if is_html
303
- body = rewrite_preview_paths(body, port)
351
+ body = inject_nav_report(rewrite_preview_paths(body, port))
304
352
  elsif is_css
305
353
  body = rewrite_preview_css(body, port)
306
354
  end
@@ -319,6 +367,59 @@ module Clacky
319
367
  stripped.empty? ? nil : stripped
320
368
  end
321
369
 
370
+ # Rewrites an upstream Set-Cookie so it survives inside the preview
371
+ # frame. The frame is a third-party context for the host UI, where Chrome
372
+ # drops SameSite=Lax/Strict cookies outright, so the session cookie the
373
+ # dev server just set would be discarded and every page would look like a
374
+ # fresh, cookie-less visit. SameSite=None needs Secure, which Chrome
375
+ # accepts over http for *.localhost because it is a trustworthy origin.
376
+ # Domain is dropped so the cookie stays pinned to the preview origin and
377
+ # can never be read by the host UI.
378
+ private def preview_cookie(value)
379
+ parts = value.to_s.split(";").map(&:strip).reject(&:empty?)
380
+ return nil if parts.empty?
381
+
382
+ kept = parts[1..-1].reject do |attr|
383
+ attr =~ /\A(?:domain|samesite)\s*=/i || attr =~ /\Asecure\z/i
384
+ end
385
+ ([parts.first] + kept + ["SameSite=None", "Secure"]).join("; ")
386
+ end
387
+
388
+ private def isolated_preview_request?(req)
389
+ req["Host"].to_s.split(":").first.to_s.downcase == ISOLATED_PREVIEW_HOST
390
+ end
391
+
392
+ # An upstream 3xx points at the dev server root (/dashboard). Inside the
393
+ # isolated preview frame that resolves against the proxy origin and loses
394
+ # the /preview/p/<port> prefix, so the redirect lands on a 404 — and the
395
+ # address bar never sees the new page. Keep root-relative redirects inside
396
+ # the prefix and fold absolute ones that point back at the same dev
397
+ # server. Off-box and already-prefixed URLs pass through untouched.
398
+ private def rewrite_preview_location(value, port, req)
399
+ return value unless isolated_preview_request?(req)
400
+
401
+ prefix = "/preview/p/#{port}"
402
+ if value.start_with?("/") && !value.start_with?("//")
403
+ return value if value == prefix || value.start_with?("#{prefix}/", "#{prefix}?")
404
+
405
+ return "#{prefix}#{value}"
406
+ end
407
+
408
+ uri = begin
409
+ URI.parse(value)
410
+ rescue URI::InvalidURIError
411
+ return value
412
+ end
413
+ return value unless uri.host
414
+ return value unless LOOPBACK_HOSTS.include?(uri.host.downcase)
415
+ return value unless uri.port == port
416
+
417
+ path = uri.path.to_s.empty? ? "/" : uri.path
418
+ path += "?#{uri.query}" if uri.query
419
+ path += "##{uri.fragment}" if uri.fragment
420
+ "#{prefix}#{path}"
421
+ end
422
+
322
423
  # Rewrites absolute-path resource URLs (/assets/app.js) in a proxied HTML
323
424
  # page to the proxy prefix so the browser resolves them against the dev
324
425
  # server instead of the host origin. Protocol-relative (//), already
@@ -141,13 +141,15 @@ module Clacky
141
141
  list_tasks.map do |task_name|
142
142
  content = begin; read_task(task_name); rescue StandardError; ""; end
143
143
  schedule = schedule_map[task_name] || {}
144
+ cron = schedule["cron"]
144
145
  {
145
146
  "name" => task_name,
146
147
  "content" => content,
147
- "cron" => schedule["cron"],
148
+ "cron" => cron,
148
149
  "enabled" => schedule.fetch("enabled", true),
149
150
  "scheduled" => !schedule.empty?,
150
- "last_run" => last_runs[task_name]
151
+ "last_run" => last_runs[task_name],
152
+ "next_run" => cron ? cron_next_run(cron) : nil
151
153
  }
152
154
  end
153
155
  end
@@ -383,6 +385,83 @@ module Clacky
383
385
 
384
386
  # ── Cron parsing ─────────────────────────────────────────────────────────
385
387
 
388
+ # First time the 5-field cron expression fires strictly after +from+.
389
+ # Walks day by day (two years max) and only expands the hour/minute grid
390
+ # for days that match, so a yearly task costs ~365 cheap comparisons.
391
+ # Returns nil for malformed expressions and impossible ones (Feb 30).
392
+ private def cron_next_run(expr, from: Time.now)
393
+ fields = expr.to_s.strip.split(/\s+/)
394
+ return nil unless fields.size == 5
395
+
396
+ sets = [
397
+ cron_field_values(fields[0], 0, 59),
398
+ cron_field_values(fields[1], 0, 23),
399
+ cron_field_values(fields[2], 1, 31),
400
+ cron_field_values(fields[3], 1, 12),
401
+ cron_field_values(fields[4], 0, 6)
402
+ ]
403
+ return nil if sets.any?(&:nil?)
404
+
405
+ minutes, hours, doms, months, dows = sets
406
+ return nil if minutes.empty? || hours.empty? || doms.empty? || months.empty? || dows.empty?
407
+
408
+ # Anchor at noon so a day-step never lands on the wrong date across a
409
+ # DST boundary (Time#+ is absolute seconds, not calendar days).
410
+ noon = Time.local(from.year, from.month, from.day, 12)
411
+ # Minute-of-day of the first candidate; same-day scans start here.
412
+ floor = from.hour * 60 + from.min + 1
413
+
414
+ (0..731).each do |offset|
415
+ day = noon + offset * 86_400
416
+ next unless months.include?(day.month) && doms.include?(day.day) && dows.include?(day.wday)
417
+
418
+ limit = offset.zero? ? floor : 0
419
+ hours.each do |h|
420
+ next if h * 60 + minutes.last < limit
421
+
422
+ minutes.each do |m|
423
+ next if h * 60 + m < limit
424
+
425
+ return Time.local(day.year, day.month, day.day, h, m, 0)
426
+ end
427
+ end
428
+ end
429
+
430
+ nil
431
+ end
432
+
433
+ # Concrete values a single cron field can take, mirroring the semantics of
434
+ # #cron_part_matches?. Returns nil when the field is unparseable.
435
+ private def cron_field_values(field, min, max)
436
+ values = []
437
+ field.to_s.split(",").each do |part|
438
+ part = part.strip
439
+ base, step = part.split("/")
440
+ step = step ? step.to_i : 1
441
+ return nil if step <= 0
442
+
443
+ lo, hi = if base == "*"
444
+ [min, max]
445
+ elsif base.include?("-")
446
+ base.split("-").map(&:to_i)
447
+ else
448
+ value = Integer(base, exception: false)
449
+ return nil if value.nil?
450
+ [value, value]
451
+ end
452
+ return nil if lo.nil? || hi.nil? || hi < lo
453
+
454
+ (lo..hi).each do |v|
455
+ next unless v.between?(min, max)
456
+ # `*` steps from zero (matches value % step), ranges step from lo.
457
+ next unless base == "*" ? (v % step).zero? : ((v - lo) % step).zero?
458
+
459
+ values << v
460
+ end
461
+ end
462
+ values.uniq.sort
463
+ end
464
+
386
465
  # Returns true if the 5-field cron expression matches the given Time.
387
466
  # Fields: minute hour day-of-month month day-of-week
388
467
  private def cron_matches?(expr, time)