1use anyhow::{Context, Result};
2use fission::op::{Color, Fill};
3use fission::prelude::*;
4use fission_command_core::{read_project_config, DistributionProvider, Target};
5use fission_command_package::{
6 package_silent, CheckSeverity, CheckStatus, PackageFormat, PackageOptions, PublishShellOptions,
7 ReadinessCheck,
8};
9use fission_command_release::{publish_workflow, PublishWorkflowOptions, ReleasePlanSnapshot};
10use serde::{Deserialize, Serialize};
11use std::env;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::{Arc, Mutex};
16use std::thread;
17
18mod fission_toml;
19mod fs_ops;
20mod snapshot;
21mod style;
22#[cfg(test)]
23mod tests;
24mod widgets;
25
26use fission_toml::*;
27use fs_ops::*;
28use snapshot::*;
29use style::theme_for_mode;
30pub use widgets::PublishApp;
31
32#[derive(Clone, Debug)]
33pub struct PublishUiOptions {
34 pub project_dir: PathBuf,
35 pub provider: DistributionProvider,
36 pub target: Option<Target>,
37 pub format: Option<PackageFormat>,
38 pub artifact: Option<PathBuf>,
39 pub site: String,
40 pub deploy: Option<String>,
41 pub track: Option<String>,
42 pub locales: Vec<String>,
43 pub screenshot: Option<PathBuf>,
44 pub exit_after_render: bool,
45 pub width: Option<u16>,
46 pub height: Option<u16>,
47 pub native_file_dialog: bool,
48}
49
50pub fn run_publish_tui(options: PublishUiOptions) -> Result<()> {
51 let run_options = fission::terminal::TerminalRunOptions {
52 width: options.width,
53 height: options.height,
54 screenshot: options.screenshot.clone(),
55 exit_after_render: options.exit_after_render,
56 ..fission::terminal::TerminalRunOptions::default()
57 };
58 let mut options = options;
59 options.native_file_dialog = false;
60 let state = PublishUiState::load(options);
61 fission::terminal::TerminalApp::with_state(PublishApp, state)
62 .with_title("Fission publish")
63 .with_env(|env| env.theme = fission::theme::Theme::dark())
64 .with_sync_env(|state, env| env.theme = theme_for_mode(state.theme_mode))
65 .with_key_handler(publish_key_handler)
66 .with_state_update(|state, _runtime, _env| state.poll_background_tasks())
67 .run_with_options(run_options)
68}
69
70pub fn run_publish_window(options: PublishUiOptions) -> Result<()> {
71 let mut options = options;
72 options.native_file_dialog = true;
73 let state = PublishUiState::load(options);
74 fission::DesktopApp::<PublishUiState, _>::new_with_global_state(PublishApp, state)
75 .with_title("Fission Publish")
76 .with_sync_env(|state, env| env.theme = theme_for_mode(state.theme_mode))
77 .with_key_handler(publish_key_handler)
78 .with_frame_hook(|state| state.poll_background_tasks())
79 .run()
80}
81
82fn publish_key_handler(
83 state: &mut PublishUiState,
84 code: &fission::KeyCode,
85 _modifiers: u8,
86) -> bool {
87 state.handle_key(code)
88}
89
90#[derive(Clone, Debug, PartialEq)]
91pub struct PublishUiState {
92 pub project_dir: PathBuf,
93 pub app_name: String,
94 pub app_id: String,
95 pub board: PublishBoard,
96 pub current_step: usize,
97 pub target: Target,
98 pub format: PackageFormat,
99 pub provider: DistributionProvider,
100 pub site: String,
101 pub deploy: Option<String>,
102 pub track: String,
103 pub locales_input: String,
104 pub workspace: PathBuf,
105 pub artifact_manifest: PathBuf,
106 pub package_checks: Vec<UiCheck>,
107 pub distribution_checks: Vec<UiCheck>,
108 pub release_checks: Vec<UiCheck>,
109 pub release_plan: Option<ReleasePlanSnapshot>,
110 pub status_message: String,
111 pub theme_mode: ThemeMode,
112 pub file_picker: Option<FilePickerState>,
113 pub selected_file: Option<FileSelection>,
114 pub(crate) config_editor: Option<FissionTomlEditorState>,
115 pub play_json_path: String,
116 pub android_jks_path: String,
117 pub android_alias: String,
118 pub android_password: String,
119 pub app_store_key_path: String,
120 pub app_store_key_id: String,
121 pub app_store_issuer_id: String,
122 pub windows_pfx_path: String,
123 pub windows_password: String,
124 pub azure_tenant_id: String,
125 pub azure_client_id: String,
126 pub microsoft_secret: String,
127 pub aws_profile: String,
128 pub aws_region: String,
129 pub aws_endpoint: String,
130 pub aws_access_key_id: String,
131 pub aws_secret_access_key: String,
132 pub publish_confirmation: String,
133 pub task: Option<PublishTaskState>,
134 pub task_revision_seen: u64,
135 pub task_log: Vec<String>,
136 pub(crate) snapshot_task: Option<SnapshotRefreshState>,
137 pub snapshot_task_revision_seen: u64,
138 pub native_file_dialog: bool,
139}
140
141impl GlobalState for PublishUiState {}
142
143impl Default for PublishUiState {
144 fn default() -> Self {
145 Self::load(default_publish_options(PathBuf::from(".")))
146 }
147}
148
149impl PublishUiState {
150 pub fn load(options: PublishUiOptions) -> Self {
151 let board = PublishBoard::from_provider(options.provider);
152 let target = options.target.unwrap_or_else(|| board.target());
153 let format = options.format.unwrap_or_else(|| board.format());
154 let provider = options.provider;
155 let track = options
156 .track
157 .clone()
158 .unwrap_or_else(|| board.default_track().to_string());
159 let locales_input = if options.locales.is_empty() {
160 "".to_string()
161 } else {
162 options.locales.join(", ")
163 };
164 let mut state = Self {
165 project_dir: options.project_dir.clone(),
166 app_name: "workspace".to_string(),
167 app_id: "unknown".to_string(),
168 board,
169 current_step: 1,
170 target,
171 format,
172 provider,
173 site: options.site,
174 deploy: options.deploy,
175 track,
176 locales_input,
177 workspace: PathBuf::new(),
178 artifact_manifest: options.artifact.unwrap_or_default(),
179 package_checks: Vec::new(),
180 distribution_checks: Vec::new(),
181 release_checks: Vec::new(),
182 release_plan: None,
183 status_message: "Loading project".to_string(),
184 theme_mode: ThemeMode::Dark,
185 file_picker: None,
186 selected_file: None,
187 config_editor: None,
188 play_json_path: String::new(),
189 android_jks_path: String::new(),
190 android_alias: String::new(),
191 android_password: String::new(),
192 app_store_key_path: String::new(),
193 app_store_key_id: String::new(),
194 app_store_issuer_id: String::new(),
195 windows_pfx_path: String::new(),
196 windows_password: String::new(),
197 azure_tenant_id: String::new(),
198 azure_client_id: String::new(),
199 microsoft_secret: String::new(),
200 aws_profile: String::new(),
201 aws_region: String::new(),
202 aws_endpoint: String::new(),
203 aws_access_key_id: String::new(),
204 aws_secret_access_key: String::new(),
205 publish_confirmation: String::new(),
206 task: None,
207 task_revision_seen: 0,
208 task_log: Vec::new(),
209 snapshot_task: None,
210 snapshot_task_revision_seen: 0,
211 native_file_dialog: options.native_file_dialog,
212 };
213 state.refresh_snapshot();
214 state.load_release_env_values();
215 state
216 }
217
218 fn options(&self) -> PublishShellOptions {
219 PublishShellOptions {
220 project_dir: self.project_dir.clone(),
221 provider: self.provider,
222 target: Some(self.target),
223 format: Some(self.format),
224 artifact: if self.artifact_manifest.as_os_str().is_empty() {
225 None
226 } else {
227 Some(self.artifact_manifest.clone())
228 },
229 site: self.site.clone(),
230 deploy: self.deploy.clone(),
231 track: Some(self.track.clone()).filter(|value| !value.trim().is_empty()),
232 locales: self
233 .locales_input
234 .split(',')
235 .map(str::trim)
236 .filter(|value| !value.is_empty())
237 .map(str::to_string)
238 .collect(),
239 overwrite_remote: false,
240 dry_run: false,
241 yes: false,
242 json: false,
243 app: false,
244 }
245 }
246
247 fn refresh_snapshot(&mut self) {
248 match collect_refresh_snapshot(self.options()) {
249 Ok(result) => self.apply_refresh_result(result),
250 Err(err) => self.apply_refresh_error(err.to_string()),
251 }
252 }
253
254 fn apply_refresh_result(&mut self, result: SnapshotRefreshResult) {
255 let snapshot = result.snapshot;
256 self.app_name = snapshot.app_name;
257 self.app_id = snapshot.app_id;
258 self.provider = snapshot.provider;
259 self.target = snapshot.target;
260 self.format = snapshot.format;
261 self.site = snapshot.site;
262 self.track = snapshot
263 .track
264 .unwrap_or_else(|| self.board.default_track().to_string());
265 if !snapshot.locales.is_empty() && self.locales_input.trim().is_empty() {
266 self.locales_input = snapshot.locales.join(", ");
267 }
268 self.workspace = snapshot.workspace;
269 self.artifact_manifest = snapshot.artifact_manifest;
270 self.package_checks = snapshot
271 .package_checks
272 .into_iter()
273 .map(UiCheck::from)
274 .collect();
275 self.distribution_checks = snapshot
276 .distribution_checks
277 .into_iter()
278 .map(UiCheck::from)
279 .collect();
280 self.release_plan = result.release_plan;
281 self.release_checks = result
282 .release_checks
283 .into_iter()
284 .map(UiCheck::from)
285 .collect();
286 self.status_message = "Preflight refreshed".to_string();
287 if self.android_alias.trim().is_empty() {
288 self.android_alias = sanitize_workspace_name(&self.app_name).replace('.', "-");
289 }
290 }
291
292 fn apply_refresh_error(&mut self, err: String) {
293 self.status_message = format!("Preflight failed: {err}");
294 self.package_checks = vec![UiCheck::failed("Project could not be loaded", err)];
295 self.distribution_checks.clear();
296 self.release_checks.clear();
297 self.release_plan = None;
298 }
299
300 fn start_snapshot_refresh(&mut self) {
301 if self
302 .snapshot_task
303 .as_ref()
304 .is_some_and(|task| task.status() == TaskStatus::Running)
305 {
306 self.status_message = "Preflight refresh is already running".to_string();
307 return;
308 }
309 let options = self.options();
310 let task = SnapshotRefreshState::new();
311 let shared = task.shared.clone();
312 thread::spawn(move || {
313 let result = collect_refresh_snapshot(options).map_err(|err| err.to_string());
314 let mut data = shared.lock().expect("snapshot refresh lock poisoned");
315 data.status = if result.is_ok() {
316 TaskStatus::Ok
317 } else {
318 TaskStatus::Failed
319 };
320 data.message = match &result {
321 Ok(_) => "Preflight refreshed".to_string(),
322 Err(err) => format!("Preflight failed: {err}"),
323 };
324 data.result = Some(result);
325 data.revision = data.revision.saturating_add(1);
326 });
327 self.snapshot_task = Some(task);
328 self.snapshot_task_revision_seen = 0;
329 self.status_message = "Refreshing preflight...".to_string();
330 }
331
332 fn load_release_env_values(&mut self) {
333 let env_path = self.workspace.join("release.env");
334 let Ok(entries) = read_env_entries(&env_path) else {
335 return;
336 };
337 self.play_json_path = entries
338 .get("GOOGLE_APPLICATION_CREDENTIALS")
339 .cloned()
340 .unwrap_or_default();
341 self.android_jks_path = entries.get("ANDROID_KEYSTORE").cloned().unwrap_or_default();
342 self.android_alias = entries
343 .get("ANDROID_KEYSTORE_ALIAS")
344 .cloned()
345 .unwrap_or_else(|| self.android_alias.clone());
346 self.app_store_key_path = entries
347 .get("APP_STORE_CONNECT_API_KEY_PATH")
348 .cloned()
349 .unwrap_or_default();
350 self.app_store_key_id = entries
351 .get("APP_STORE_CONNECT_KEY_ID")
352 .cloned()
353 .unwrap_or_default();
354 self.app_store_issuer_id = entries
355 .get("APP_STORE_CONNECT_ISSUER_ID")
356 .cloned()
357 .unwrap_or_default();
358 self.windows_pfx_path = entries
359 .get("WINDOWS_CERTIFICATE")
360 .cloned()
361 .unwrap_or_default();
362 self.azure_tenant_id = entries.get("AZURE_TENANT_ID").cloned().unwrap_or_default();
363 self.azure_client_id = entries.get("AZURE_CLIENT_ID").cloned().unwrap_or_default();
364 self.aws_profile = entries.get("AWS_PROFILE").cloned().unwrap_or_default();
365 self.aws_region = entries.get("AWS_REGION").cloned().unwrap_or_default();
366 self.aws_endpoint = entries
367 .get("AWS_ENDPOINT_URL_S3")
368 .cloned()
369 .unwrap_or_default();
370 self.aws_access_key_id = entries
371 .get("AWS_ACCESS_KEY_ID")
372 .cloned()
373 .unwrap_or_default();
374 }
375
376 fn save_env_value(&mut self, key: &str, value: &str) {
377 if value.trim().is_empty() {
378 self.status_message = format!("Skipped empty {key}");
379 return;
380 }
381 let env_path = self.workspace.join("release.env");
382 match upsert_env(&env_path, key, value.trim()) {
383 Ok(()) => self.status_message = format!("Saved {key} to {}", env_path.display()),
384 Err(err) => self.status_message = format!("Failed to save {key}: {err}"),
385 }
386 }
387
388 fn save_current_credentials(&mut self) {
389 match self.board {
390 PublishBoard::Android => {
391 let values = [
392 (
393 "GOOGLE_APPLICATION_CREDENTIALS",
394 self.play_json_path.clone(),
395 ),
396 ("ANDROID_KEYSTORE", self.android_jks_path.clone()),
397 ("ANDROID_KEYSTORE_ALIAS", self.android_alias.clone()),
398 ("ANDROID_KEYSTORE_PASSWORD", self.android_password.clone()),
399 ("ANDROID_KEY_PASSWORD", self.android_password.clone()),
400 ];
401 self.save_values(&values);
402 }
403 PublishBoard::Ios => {
404 let values = [
405 (
406 "APP_STORE_CONNECT_API_KEY_PATH",
407 self.app_store_key_path.clone(),
408 ),
409 ("APP_STORE_CONNECT_KEY_ID", self.app_store_key_id.clone()),
410 (
411 "APP_STORE_CONNECT_ISSUER_ID",
412 self.app_store_issuer_id.clone(),
413 ),
414 ];
415 self.save_values(&values);
416 }
417 PublishBoard::Windows => {
418 let values = [
419 ("WINDOWS_CERTIFICATE", self.windows_pfx_path.clone()),
420 (
421 "WINDOWS_CERTIFICATE_PASSWORD",
422 self.windows_password.clone(),
423 ),
424 ("AZURE_TENANT_ID", self.azure_tenant_id.clone()),
425 ("AZURE_CLIENT_ID", self.azure_client_id.clone()),
426 (
427 "MICROSOFT_STORE_CLIENT_SECRET",
428 self.microsoft_secret.clone(),
429 ),
430 ];
431 self.save_values(&values);
432 }
433 PublishBoard::S3 => {
434 let values = [
435 ("AWS_PROFILE", self.aws_profile.clone()),
436 ("AWS_REGION", self.aws_region.clone()),
437 ("AWS_ENDPOINT_URL_S3", self.aws_endpoint.clone()),
438 ("AWS_ACCESS_KEY_ID", self.aws_access_key_id.clone()),
439 ("AWS_SECRET_ACCESS_KEY", self.aws_secret_access_key.clone()),
440 ];
441 self.save_values(&values);
442 }
443 }
444 self.start_snapshot_refresh();
445 }
446
447 fn save_values(&mut self, values: &[(&str, String)]) {
448 let env_path = self.workspace.join("release.env");
449 let saved = values
450 .iter()
451 .filter_map(|(key, value)| {
452 let value = value.trim();
453 (!value.is_empty()).then_some((*key, value.to_string()))
454 })
455 .collect::<Vec<_>>();
456 let result = saved
457 .iter()
458 .try_for_each(|(key, value)| upsert_env(&env_path, key, value));
459 match result {
460 Ok(()) => {
461 for (key, value) in saved {
462 env::set_var(key, value);
463 }
464 self.status_message = format!("Saved release settings to {}", env_path.display())
465 }
466 Err(err) => self.status_message = format!("Failed to save release settings: {err}"),
467 }
468 }
469
470 fn open_file_picker(&mut self, purpose: FilePurpose) {
471 if self.native_file_dialog {
472 self.open_native_file_dialog(purpose);
473 return;
474 }
475 let current_dir = if self.project_dir.exists() {
476 self.project_dir.clone()
477 } else {
478 env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
479 };
480 self.file_picker = Some(FilePickerState::new(purpose, current_dir));
481 }
482
483 fn open_native_file_dialog(&mut self, purpose: FilePurpose) {
484 let mut dialog = rfd::FileDialog::new().set_title(purpose.title());
485 if self.project_dir.exists() {
486 dialog = dialog.set_directory(&self.project_dir);
487 }
488 if let Some((name, extensions)) = purpose.file_filter() {
489 dialog = dialog.add_filter(name, extensions);
490 }
491 match dialog.pick_file() {
492 Some(path) => {
493 self.selected_file = Some(FileSelection { purpose, path });
494 self.file_picker = None;
495 self.status_message = format!("Selected {}", purpose.title());
496 }
497 None => {
498 self.status_message = format!("No file selected for {}", purpose.title());
499 }
500 }
501 }
502
503 fn choose_file_entry(&mut self, index: usize) {
504 let Some(picker) = &mut self.file_picker else {
505 return;
506 };
507 picker.refresh();
508 picker.selected_index = index.min(picker.entries.len());
509 if index == 0 {
510 if let Some(parent) = picker.current_dir.parent() {
511 picker.current_dir = parent.to_path_buf();
512 picker.refresh();
513 picker.selected_index = 0;
514 }
515 return;
516 }
517 let Some(entry) = picker.entries.get(index.saturating_sub(1)).cloned() else {
518 return;
519 };
520 if entry.is_dir {
521 picker.current_dir = entry.path;
522 picker.refresh();
523 picker.selected_index = 0;
524 } else {
525 self.selected_file = Some(FileSelection {
526 purpose: picker.purpose,
527 path: entry.path,
528 });
529 }
530 }
531
532 fn handle_key(&mut self, code: &fission::KeyCode) -> bool {
533 if self.config_editor.is_some() && matches!(code, fission::KeyCode::Escape) {
534 self.config_editor = None;
535 return true;
536 }
537 if self.file_picker.is_some() {
538 return self.handle_file_picker_key(code);
539 }
540 match code {
541 fission::KeyCode::Left | fission::KeyCode::Up => {
542 self.previous_step();
543 true
544 }
545 fission::KeyCode::Right | fission::KeyCode::Down | fission::KeyCode::Enter => {
546 self.next_step();
547 true
548 }
549 _ => false,
550 }
551 }
552
553 fn handle_file_picker_key(&mut self, code: &fission::KeyCode) -> bool {
554 let Some(picker) = &mut self.file_picker else {
555 return false;
556 };
557 match code {
558 fission::KeyCode::Up => {
559 picker.selected_index = picker.selected_index.saturating_sub(1);
560 true
561 }
562 fission::KeyCode::Down => {
563 picker.selected_index = (picker.selected_index + 1).min(picker.entries.len());
564 true
565 }
566 fission::KeyCode::Enter | fission::KeyCode::Right => {
567 let index = picker.selected_index;
568 self.choose_file_entry(index);
569 true
570 }
571 fission::KeyCode::Escape | fission::KeyCode::Left => {
572 self.file_picker = None;
573 true
574 }
575 _ => false,
576 }
577 }
578
579 fn apply_selected_file(&mut self, action: FileAction) {
580 let Some(selection) = self.selected_file.take() else {
581 return;
582 };
583 let Some(dest_name) = selection.purpose.default_name(&selection.path) else {
584 return;
585 };
586 let folder = match selection.purpose {
587 FilePurpose::AppStoreKey => self.workspace.join("ios"),
588 FilePurpose::WindowsCertificate => self.workspace.join("windows"),
589 _ => self.workspace.clone(),
590 };
591 let result = match action {
592 FileAction::Reference => {
593 if path_is_inside_project(&selection.path, &self.project_dir) {
594 Err(anyhow::anyhow!(
595 "refusing to reference a secret file inside the project tree; copy or move it to {} instead",
596 folder.display()
597 ))
598 } else {
599 Ok(selection.path.clone())
600 }
601 }
602 FileAction::Copy => {
603 copy_or_move_selected_file(&selection.path, &folder, &dest_name, false)
604 }
605 FileAction::Move => {
606 copy_or_move_selected_file(&selection.path, &folder, &dest_name, true)
607 }
608 };
609 match result {
610 Ok(path) => {
611 let value = path.display().to_string();
612 match selection.purpose {
613 FilePurpose::PlayServiceJson => self.play_json_path = value.clone(),
614 FilePurpose::AndroidKeystore => self.android_jks_path = value.clone(),
615 FilePurpose::AppStoreKey => self.app_store_key_path = value.clone(),
616 FilePurpose::WindowsCertificate => self.windows_pfx_path = value.clone(),
617 }
618 self.save_env_value(selection.purpose.env_key(), &value);
619 self.file_picker = None;
620 }
621 Err(err) => self.status_message = format!("File selection failed: {err}"),
622 }
623 }
624
625 fn generate_android_key(&mut self) {
626 if self.android_password.trim().is_empty() {
627 self.status_message = "Enter an Android keystore password first".to_string();
628 return;
629 }
630 let alias = if self.android_alias.trim().is_empty() {
631 sanitize_workspace_name(&self.app_name).replace('.', "-")
632 } else {
633 self.android_alias.clone()
634 };
635 let dest = self.workspace.join("upload-key.jks");
636 let task_dest = dest.clone();
637 let password = self.android_password.clone();
638 let task_alias = alias.clone();
639 self.start_task(PublishTaskKind::GenerateAndroidKey, move || {
640 let status = Command::new("keytool")
641 .arg("-genkeypair")
642 .arg("-v")
643 .arg("-keystore")
644 .arg(&task_dest)
645 .arg("-storepass")
646 .arg(&password)
647 .arg("-keypass")
648 .arg(&password)
649 .arg("-alias")
650 .arg(&task_alias)
651 .arg("-keyalg")
652 .arg("RSA")
653 .arg("-keysize")
654 .arg("2048")
655 .arg("-validity")
656 .arg("9125")
657 .arg("-dname")
658 .arg("CN=Fission Upload, OU=Fission Local Publish, O=Fission, L=Local, ST=Local, C=US")
659 .stdout(Stdio::piped())
660 .stderr(Stdio::piped())
661 .spawn()
662 .context("failed to run keytool")?
663 .wait_with_output()
664 .context("failed to wait for keytool")?;
665 let mut out = String::new();
666 out.push_str(&String::from_utf8_lossy(&status.stdout));
667 out.push_str(&String::from_utf8_lossy(&status.stderr));
668 if !status.status.success() {
669 anyhow::bail!("keytool failed\n{out}");
670 }
671 Ok(out)
672 });
673 self.android_jks_path = dest.display().to_string();
674 let values = [
675 ("ANDROID_KEYSTORE", self.android_jks_path.clone()),
676 ("ANDROID_KEYSTORE_ALIAS", alias),
677 ("ANDROID_KEYSTORE_PASSWORD", self.android_password.clone()),
678 ("ANDROID_KEY_PASSWORD", self.android_password.clone()),
679 ];
680 self.save_values(&values);
681 }
682
683 fn start_cli_task(&mut self, kind: PublishTaskKind) {
684 let task = self.task_request(kind);
685 self.start_task(kind, move || task.run())
686 }
687
688 fn skip_requirement(&mut self, id: String) {
689 match fission_command_release::skip_release_requirement(&self.project_dir, &id, true) {
690 Ok(()) => {
691 self.status_message = format!("Skipped recommended release check {id}");
692 self.start_snapshot_refresh();
693 }
694 Err(err) => self.status_message = format!("Failed to skip {id}: {err}"),
695 }
696 }
697
698 fn task_request(&self, kind: PublishTaskKind) -> PublishTaskRequest {
699 PublishTaskRequest {
700 kind,
701 project_dir: self.project_dir.clone(),
702 provider: self.provider,
703 target: self.target,
704 format: self.format,
705 artifact: if self.artifact_manifest.as_os_str().is_empty() {
706 None
707 } else {
708 Some(self.artifact_manifest.clone())
709 },
710 site: self.site.clone(),
711 deploy: self.deploy.clone(),
712 track: Some(self.track.clone()).filter(|value| !value.trim().is_empty()),
713 locales: self
714 .locales_input
715 .split(',')
716 .map(str::trim)
717 .filter(|value| !value.is_empty())
718 .map(str::to_string)
719 .collect(),
720 }
721 }
722
723 fn start_task<F>(&mut self, kind: PublishTaskKind, run: F)
724 where
725 F: FnOnce() -> Result<String> + Send + 'static,
726 {
727 if self
728 .task
729 .as_ref()
730 .is_some_and(|task| task.status() == TaskStatus::Running)
731 {
732 self.status_message = "A publish task is already running".to_string();
733 return;
734 }
735 let task = PublishTaskState::new(kind);
736 let shared = task.shared.clone();
737 thread::spawn(move || {
738 let result = run();
739 let mut data = shared.lock().expect("publish task lock poisoned");
740 match result {
741 Ok(output) => {
742 data.status = TaskStatus::Ok;
743 data.output = redact_output_lines(&output);
744 if data.output.is_empty() {
745 data.output.push("done".to_string());
746 }
747 }
748 Err(err) => {
749 data.status = TaskStatus::Failed;
750 data.output = redact_output_lines(&err.to_string());
751 }
752 }
753 data.revision = data.revision.saturating_add(1);
754 });
755 self.task = Some(task);
756 self.status_message = format!("Started {}", kind.label());
757 }
758
759 fn poll_task(&mut self) -> bool {
760 let Some(task) = &self.task else {
761 return false;
762 };
763 let revision = task.revision();
764 if self.task_revision_seen == revision {
765 return false;
766 }
767 self.task_revision_seen = revision;
768 self.task_log = task.output();
769 if task.status() != TaskStatus::Running {
770 self.status_message = format!("{}: {}", task.kind.label(), task.status().label());
771 self.start_snapshot_refresh();
772 }
773 true
774 }
775
776 fn poll_snapshot_task(&mut self) -> bool {
777 let Some(task) = &self.snapshot_task else {
778 return false;
779 };
780 let revision = task.revision();
781 if self.snapshot_task_revision_seen == revision {
782 return false;
783 }
784 self.snapshot_task_revision_seen = revision;
785 if task.status() == TaskStatus::Running {
786 self.status_message = task.message();
787 return true;
788 }
789 let result = task.result();
790 self.snapshot_task = None;
791 self.snapshot_task_revision_seen = 0;
792 match result {
793 Some(Ok(result)) => {
794 self.apply_refresh_result(result);
795 if let Some(editor) = &mut self.config_editor {
796 editor.status_message = "Saved and readiness refreshed.".to_string();
797 }
798 }
799 Some(Err(err)) => {
800 self.apply_refresh_error(err.clone());
801 if let Some(editor) = &mut self.config_editor {
802 editor.status_message = format!("Saved, but readiness refresh failed: {err}");
803 }
804 }
805 None => self.status_message = "Preflight refresh finished without a result".to_string(),
806 }
807 true
808 }
809
810 fn poll_background_tasks(&mut self) -> bool {
811 let task_changed = self.poll_task();
812 let snapshot_changed = self.poll_snapshot_task();
813 task_changed || snapshot_changed
814 }
815
816 fn is_ready_to_publish(&self) -> bool {
817 Self::check_group_ready(&self.package_checks)
818 && Self::check_group_ready(&self.distribution_checks)
819 && Self::check_group_ready(&self.release_checks)
820 && self.publish_confirmation.trim() == self.app_id
821 }
822
823 fn check_group_ready(checks: &[UiCheck]) -> bool {
824 !checks.is_empty() && checks.iter().all(UiCheck::is_non_blocking)
825 }
826
827 fn next_step(&mut self) {
828 self.current_step = (self.current_step + 1).min(self.board.step_count());
829 }
830
831 fn previous_step(&mut self) {
832 self.current_step = self.current_step.saturating_sub(1).max(1);
833 }
834
835 fn go_to_step(&mut self, step: usize) {
836 self.current_step = step.clamp(1, self.board.step_count());
837 }
838
839 fn open_config_editor(&mut self, field: Option<String>) {
840 self.config_editor = Some(FissionTomlEditorState::load(&self.project_dir, field));
841 }
842
843 fn select_config_field(&mut self, field: String) {
844 if self.config_editor.is_none() {
845 self.open_config_editor(Some(field.clone()));
846 }
847 let value = read_fission_toml_field(&self.project_dir, &field).unwrap_or_default();
848 if let Some(editor) = &mut self.config_editor {
849 editor.selected_preset = field_specs()
850 .iter()
851 .position(|spec| spec.path == field)
852 .unwrap_or(editor.selected_preset);
853 editor.field_path = field;
854 editor.value = value;
855 editor.status_message = "Loaded current field value from fission.toml.".to_string();
856 }
857 }
858
859 fn apply_config_editor_field(&mut self) {
860 let Some((field_path, value)) = self
861 .config_editor
862 .as_ref()
863 .map(|editor| (editor.field_path.clone(), editor.value.clone()))
864 else {
865 return;
866 };
867 match apply_fission_toml_field(&self.project_dir, &field_path, &value) {
868 Ok(message) => {
869 if let Some(editor) = &mut self.config_editor {
870 editor.status_message = format!("{message}. Refreshing readiness...");
871 }
872 self.start_snapshot_refresh();
873 }
874 Err(err) => {
875 if let Some(editor) = &mut self.config_editor {
876 editor.status_message = format!("Failed to update fission.toml: {err}");
877 }
878 }
879 }
880 }
881}
882
883#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
884pub enum PublishBoard {
885 Android,
886 Ios,
887 Windows,
888 S3,
889}
890
891impl PublishBoard {
892 fn from_provider(provider: DistributionProvider) -> Self {
893 match provider {
894 DistributionProvider::AppStore => Self::Ios,
895 DistributionProvider::MicrosoftStore => Self::Windows,
896 DistributionProvider::S3 => Self::S3,
897 _ => Self::Android,
898 }
899 }
900
901 fn target(self) -> Target {
902 match self {
903 Self::Android => Target::Android,
904 Self::Ios => Target::Ios,
905 Self::Windows => Target::Windows,
906 Self::S3 => Target::Site,
907 }
908 }
909
910 fn format(self) -> PackageFormat {
911 match self {
912 Self::Android => PackageFormat::Aab,
913 Self::Ios => PackageFormat::Ipa,
914 Self::Windows => PackageFormat::Msix,
915 Self::S3 => PackageFormat::Static,
916 }
917 }
918
919 fn default_track(self) -> &'static str {
920 match self {
921 Self::Android => "internal",
922 Self::Ios => "testflight",
923 Self::Windows => "private",
924 Self::S3 => "",
925 }
926 }
927
928 fn step_count(self) -> usize {
929 7
930 }
931}
932
933#[derive(Clone, Debug, Eq, PartialEq)]
934pub struct UiCheck {
935 pub id: String,
936 pub severity: CheckSeverity,
937 pub status: CheckStatus,
938 pub summary: String,
939 pub details: Option<String>,
940 pub remediation: Vec<String>,
941}
942
943impl UiCheck {
944 fn failed(summary: impl Into<String>, details: impl Into<String>) -> Self {
945 Self {
946 id: "publish.ui.failed".to_string(),
947 severity: CheckSeverity::Error,
948 status: CheckStatus::Failed,
949 summary: summary.into(),
950 details: Some(details.into()),
951 remediation: Vec::new(),
952 }
953 }
954
955 fn is_non_blocking(&self) -> bool {
956 self.severity != CheckSeverity::Error || self.status == CheckStatus::Passed
957 }
958
959 fn needs_attention(&self) -> bool {
960 matches!(
961 self.status,
962 CheckStatus::Missing | CheckStatus::Failed | CheckStatus::Warning
963 )
964 }
965
966 fn action_hints(&self, board: PublishBoard, current_step: usize) -> Vec<PublishCheckAction> {
967 check_action_hints(self, board, current_step)
968 }
969}
970
971impl From<ReadinessCheck> for UiCheck {
972 fn from(value: ReadinessCheck) -> Self {
973 Self {
974 id: value.id,
975 severity: value.severity,
976 status: value.status,
977 summary: value.summary,
978 details: value.details,
979 remediation: value.remediation,
980 }
981 }
982}
983
984#[derive(Clone, Debug, Eq, PartialEq)]
985pub struct PublishCheckAction {
986 pub label: String,
987 pub kind: PublishCheckActionKind,
988 pub primary: bool,
989}
990
991impl PublishCheckAction {
992 fn primary(label: impl Into<String>, kind: PublishCheckActionKind) -> Self {
993 Self {
994 label: label.into(),
995 kind,
996 primary: true,
997 }
998 }
999
1000 fn secondary(label: impl Into<String>, kind: PublishCheckActionKind) -> Self {
1001 Self {
1002 label: label.into(),
1003 kind,
1004 primary: false,
1005 }
1006 }
1007}
1008
1009#[derive(Clone, Debug, Eq, PartialEq)]
1010pub enum PublishCheckActionKind {
1011 GoToStep(usize),
1012 OpenFilePicker(FilePurpose),
1013 OpenConfigEditor(String),
1014 SaveCredentials,
1015 GenerateAndroidKey,
1016 StartTask(PublishTaskKind),
1017 SkipRequirement(String),
1018 Refresh,
1019}
1020
1021fn check_action_hints(
1022 check: &UiCheck,
1023 board: PublishBoard,
1024 current_step: usize,
1025) -> Vec<PublishCheckAction> {
1026 if !check.needs_attention() {
1027 return Vec::new();
1028 }
1029 let haystack = check_search_text(check);
1030 let mut actions = Vec::new();
1031
1032 if mentions_any(
1033 &haystack,
1034 &[
1035 "google_application_credentials",
1036 "service account",
1037 "play service",
1038 "play store credential",
1039 ],
1040 ) {
1041 actions.push(PublishCheckAction::primary(
1042 "Select service JSON",
1043 PublishCheckActionKind::OpenFilePicker(FilePurpose::PlayServiceJson),
1044 ));
1045 actions.push(PublishCheckAction::secondary(
1046 "Credential step",
1047 PublishCheckActionKind::GoToStep(3),
1048 ));
1049 }
1050
1051 if mentions_any(
1052 &haystack,
1053 &["android_keystore", "keystore", "upload key", ".jks", "jks"],
1054 ) {
1055 actions.push(PublishCheckAction::primary(
1056 "Select JKS",
1057 PublishCheckActionKind::OpenFilePicker(FilePurpose::AndroidKeystore),
1058 ));
1059 actions.push(PublishCheckAction::secondary(
1060 "Generate key",
1061 PublishCheckActionKind::GenerateAndroidKey,
1062 ));
1063 actions.push(PublishCheckAction::secondary(
1064 "Signing step",
1065 PublishCheckActionKind::GoToStep(4),
1066 ));
1067 }
1068
1069 if mentions_any(
1070 &haystack,
1071 &[
1072 "app_store_connect_api_key_path",
1073 "app store connect",
1074 ".p8",
1075 "issuer id",
1076 "key id",
1077 ],
1078 ) {
1079 if mentions_any(&haystack, &[".p8", "api_key_path", "key path"]) {
1080 actions.push(PublishCheckAction::primary(
1081 "Select .p8 key",
1082 PublishCheckActionKind::OpenFilePicker(FilePurpose::AppStoreKey),
1083 ));
1084 }
1085 actions.push(PublishCheckAction::secondary(
1086 "Credential step",
1087 PublishCheckActionKind::GoToStep(4),
1088 ));
1089 actions.push(PublishCheckAction::secondary(
1090 "Save settings",
1091 PublishCheckActionKind::SaveCredentials,
1092 ));
1093 }
1094
1095 if mentions_any(
1096 &haystack,
1097 &[
1098 "windows_certificate",
1099 "certificate",
1100 ".pfx",
1101 ".p12",
1102 "signtool",
1103 ],
1104 ) {
1105 if mentions_any(&haystack, &["certificate", ".pfx", ".p12"]) {
1106 actions.push(PublishCheckAction::primary(
1107 "Select certificate",
1108 PublishCheckActionKind::OpenFilePicker(FilePurpose::WindowsCertificate),
1109 ));
1110 }
1111 actions.push(PublishCheckAction::secondary(
1112 "Signing step",
1113 PublishCheckActionKind::GoToStep(3),
1114 ));
1115 }
1116
1117 if mentions_any(
1118 &haystack,
1119 &[
1120 "azure_tenant_id",
1121 "azure_client_id",
1122 "microsoft_store_client_secret",
1123 "client secret",
1124 "tenant id",
1125 "seller id",
1126 ],
1127 ) {
1128 actions.push(PublishCheckAction::primary(
1129 "Store credential step",
1130 PublishCheckActionKind::GoToStep(4),
1131 ));
1132 actions.push(PublishCheckAction::secondary(
1133 "Save settings",
1134 PublishCheckActionKind::SaveCredentials,
1135 ));
1136 }
1137
1138 if mentions_any(
1139 &haystack,
1140 &[
1141 "aws_profile",
1142 "aws_region",
1143 "aws_access_key_id",
1144 "aws_secret_access_key",
1145 "s3",
1146 "bucket",
1147 ],
1148 ) {
1149 actions.push(PublishCheckAction::primary(
1150 "S3 settings step",
1151 PublishCheckActionKind::GoToStep(4),
1152 ));
1153 actions.push(PublishCheckAction::secondary(
1154 "Save settings",
1155 PublishCheckActionKind::SaveCredentials,
1156 ));
1157 }
1158
1159 if check.id.starts_with("release.package.")
1160 || mentions_any(
1161 &haystack,
1162 &[
1163 "artifact",
1164 "package",
1165 "rebuild",
1166 "build the",
1167 "manifest",
1168 "stale",
1169 ],
1170 )
1171 {
1172 actions.push(PublishCheckAction::primary(
1173 "Build artifact",
1174 PublishCheckActionKind::StartTask(PublishTaskKind::Package),
1175 ));
1176 actions.push(PublishCheckAction::secondary(
1177 "Build step",
1178 PublishCheckActionKind::GoToStep(match board {
1179 PublishBoard::Android => 6,
1180 PublishBoard::Ios => 6,
1181 PublishBoard::Windows => 6,
1182 PublishBoard::S3 => 5,
1183 }),
1184 ));
1185 }
1186
1187 if mentions_any(
1188 &haystack,
1189 &[
1190 "version code",
1191 "version_code",
1192 "build number",
1193 "build_number",
1194 "already been used",
1195 "release.build",
1196 ],
1197 ) {
1198 actions.push(PublishCheckAction::primary(
1199 "Bump build",
1200 PublishCheckActionKind::StartTask(PublishTaskKind::BumpBuild),
1201 ));
1202 actions.push(PublishCheckAction::secondary(
1203 "Rebuild artifact",
1204 PublishCheckActionKind::StartTask(PublishTaskKind::Package),
1205 ));
1206 }
1207
1208 if let Some(field) = config_field_for_check(check, board) {
1209 actions.push(PublishCheckAction::primary(
1210 format!("Configure {field}"),
1211 PublishCheckActionKind::OpenConfigEditor(field),
1212 ));
1213 } else if check.id.starts_with("release_config.") || check.id.starts_with("release_content.") {
1214 actions.push(PublishCheckAction::primary(
1215 "Open config editor",
1216 PublishCheckActionKind::OpenConfigEditor(String::new()),
1217 ));
1218 }
1219
1220 if actions.is_empty() {
1221 actions.push(PublishCheckAction::secondary(
1222 "Refresh after manual fix",
1223 PublishCheckActionKind::Refresh,
1224 ));
1225 }
1226
1227 if is_skippable_ui_check(check) {
1228 actions.push(PublishCheckAction::secondary(
1229 format!("Skip {}", short_check_action_label(&check.id)),
1230 PublishCheckActionKind::SkipRequirement(check.id.clone()),
1231 ));
1232 }
1233
1234 let actions = actions
1235 .into_iter()
1236 .filter(|action| !matches!(action.kind, PublishCheckActionKind::GoToStep(step) if step == current_step))
1237 .collect();
1238 dedupe_check_actions(actions)
1239}
1240
1241fn is_skippable_ui_check(check: &UiCheck) -> bool {
1242 check.severity != CheckSeverity::Error
1243 && matches!(
1244 check.status,
1245 CheckStatus::Missing | CheckStatus::Failed | CheckStatus::Warning
1246 )
1247}
1248
1249fn check_search_text(check: &UiCheck) -> String {
1250 let mut text = String::new();
1251 text.push_str(&check.id);
1252 text.push(' ');
1253 text.push_str(&check.summary);
1254 if let Some(details) = &check.details {
1255 text.push(' ');
1256 text.push_str(details);
1257 }
1258 for remediation in &check.remediation {
1259 text.push(' ');
1260 text.push_str(remediation);
1261 }
1262 text.to_ascii_lowercase()
1263}
1264
1265fn mentions_any(text: &str, needles: &[&str]) -> bool {
1266 needles.iter().any(|needle| text.contains(needle))
1267}
1268
1269fn short_check_action_label(id: &str) -> String {
1270 id.rsplit('.')
1271 .next()
1272 .filter(|value| !value.trim().is_empty())
1273 .unwrap_or(id)
1274 .replace('_', " ")
1275}
1276
1277fn dedupe_check_actions(actions: Vec<PublishCheckAction>) -> Vec<PublishCheckAction> {
1278 let mut deduped = Vec::new();
1279 for action in actions {
1280 if !deduped
1281 .iter()
1282 .any(|existing: &PublishCheckAction| existing.kind == action.kind)
1283 {
1284 deduped.push(action);
1285 }
1286 }
1287 deduped
1288}
1289
1290#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
1291pub enum ThemeMode {
1292 Light,
1293 #[default]
1294 Dark,
1295}
1296
1297#[derive(Clone, Debug, Eq, PartialEq)]
1298pub struct FilePickerState {
1299 pub purpose: FilePurpose,
1300 pub current_dir: PathBuf,
1301 pub entries: Vec<FileEntry>,
1302 pub selected_index: usize,
1303 pub error: Option<String>,
1304 pub truncated: bool,
1305}
1306
1307impl FilePickerState {
1308 fn new(purpose: FilePurpose, current_dir: PathBuf) -> Self {
1309 let mut state = Self {
1310 purpose,
1311 current_dir,
1312 entries: Vec::new(),
1313 selected_index: 0,
1314 error: None,
1315 truncated: false,
1316 };
1317 state.refresh();
1318 state
1319 }
1320
1321 fn refresh(&mut self) {
1322 self.error = None;
1323 self.truncated = false;
1324 let entries = match fs::read_dir(&self.current_dir) {
1325 Ok(entries) => entries,
1326 Err(err) => {
1327 self.entries.clear();
1328 self.error = Some(err.to_string());
1329 return;
1330 }
1331 };
1332 self.entries = entries
1333 .filter_map(Result::ok)
1334 .map(|entry| {
1335 let path = entry.path();
1336 FileEntry {
1337 label: entry.file_name().to_string_lossy().to_string(),
1338 is_dir: path.is_dir(),
1339 path,
1340 }
1341 })
1342 .collect();
1343 self.entries
1344 .sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.label.cmp(&b.label)));
1345 if self.entries.len() > 200 {
1346 self.entries.truncate(200);
1347 self.truncated = true;
1348 }
1349 self.selected_index = self.selected_index.min(self.entries.len());
1350 }
1351}
1352
1353#[derive(Clone, Debug, Eq, PartialEq)]
1354pub struct FileEntry {
1355 pub label: String,
1356 pub is_dir: bool,
1357 pub path: PathBuf,
1358}
1359
1360#[derive(Clone, Debug, Eq, PartialEq)]
1361pub struct FileSelection {
1362 pub purpose: FilePurpose,
1363 pub path: PathBuf,
1364}
1365
1366#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1367pub enum FilePurpose {
1368 PlayServiceJson,
1369 AndroidKeystore,
1370 AppStoreKey,
1371 WindowsCertificate,
1372}
1373
1374impl FilePurpose {
1375 fn title(self) -> &'static str {
1376 match self {
1377 Self::PlayServiceJson => "Select Play service account JSON",
1378 Self::AndroidKeystore => "Select Android upload key JKS",
1379 Self::AppStoreKey => "Select App Store Connect .p8 key",
1380 Self::WindowsCertificate => "Select Windows signing certificate PFX",
1381 }
1382 }
1383
1384 fn env_key(self) -> &'static str {
1385 match self {
1386 Self::PlayServiceJson => "GOOGLE_APPLICATION_CREDENTIALS",
1387 Self::AndroidKeystore => "ANDROID_KEYSTORE",
1388 Self::AppStoreKey => "APP_STORE_CONNECT_API_KEY_PATH",
1389 Self::WindowsCertificate => "WINDOWS_CERTIFICATE",
1390 }
1391 }
1392
1393 fn default_name(self, selected: &Path) -> Option<String> {
1394 match self {
1395 Self::PlayServiceJson => Some("play-service-account.json".to_string()),
1396 Self::AndroidKeystore => Some("upload-key.jks".to_string()),
1397 Self::AppStoreKey | Self::WindowsCertificate => selected
1398 .file_name()
1399 .and_then(|value| value.to_str())
1400 .map(str::to_string),
1401 }
1402 }
1403
1404 fn file_filter(self) -> Option<(&'static str, &'static [&'static str])> {
1405 match self {
1406 Self::PlayServiceJson => Some(("JSON", &["json"])),
1407 Self::AndroidKeystore => Some(("Java keystore", &["jks", "keystore"])),
1408 Self::AppStoreKey => Some(("App Store Connect key", &["p8"])),
1409 Self::WindowsCertificate => Some(("Windows certificate", &["pfx", "p12"])),
1410 }
1411 }
1412}
1413
1414#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1415pub enum FileAction {
1416 Copy,
1417 Move,
1418 Reference,
1419}
1420
1421#[derive(Clone, Debug, PartialEq)]
1422struct PublishTaskRequest {
1423 kind: PublishTaskKind,
1424 project_dir: PathBuf,
1425 provider: DistributionProvider,
1426 target: Target,
1427 format: PackageFormat,
1428 artifact: Option<PathBuf>,
1429 site: String,
1430 deploy: Option<String>,
1431 track: Option<String>,
1432 locales: Vec<String>,
1433}
1434
1435impl PublishTaskRequest {
1436 fn run(self) -> Result<String> {
1437 match self.kind {
1438 PublishTaskKind::Package => {
1439 let path = package_silent(PackageOptions {
1440 project_dir: self.project_dir,
1441 target: self.target,
1442 format: self.format,
1443 release: true,
1444 variant: None,
1445 json: false,
1446 })?;
1447 Ok(format!("artifact manifest: {}", path.display()))
1448 }
1449 PublishTaskKind::DryRun | PublishTaskKind::Publish => {
1450 let dry_run = self.kind == PublishTaskKind::DryRun;
1451 publish_workflow(PublishWorkflowOptions {
1452 project_dir: self.project_dir,
1453 provider: self.provider,
1454 target: Some(self.target),
1455 format: Some(self.format),
1456 artifact: self.artifact,
1457 site: self.site,
1458 deploy: self.deploy,
1459 track: self.track,
1460 locales: self.locales,
1461 overwrite_remote: false,
1462 dry_run,
1463 yes: !dry_run,
1464 json: false,
1465 })?;
1466 Ok("release workflow completed".to_string())
1467 }
1468 PublishTaskKind::BumpBuild => {
1469 fission_command_release::bump_release_build(
1470 &self.project_dir,
1471 Some(self.target),
1472 1,
1473 true,
1474 )?;
1475 Ok("release build bumped".to_string())
1476 }
1477 PublishTaskKind::GenerateAndroidKey => Ok("Android upload key generated".to_string()),
1478 }
1479 }
1480}
1481
1482#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1483pub enum PublishTaskKind {
1484 Package,
1485 DryRun,
1486 Publish,
1487 BumpBuild,
1488 GenerateAndroidKey,
1489}
1490
1491impl PublishTaskKind {
1492 fn label(self) -> &'static str {
1493 match self {
1494 Self::Package => "package build",
1495 Self::DryRun => "dry-run publish",
1496 Self::Publish => "publish",
1497 Self::BumpBuild => "build number bump",
1498 Self::GenerateAndroidKey => "Android upload key generation",
1499 }
1500 }
1501}
1502
1503#[derive(Clone, Debug)]
1504pub struct PublishTaskState {
1505 kind: PublishTaskKind,
1506 shared: Arc<Mutex<PublishTaskData>>,
1507}
1508
1509impl PublishTaskState {
1510 fn new(kind: PublishTaskKind) -> Self {
1511 Self {
1512 kind,
1513 shared: Arc::new(Mutex::new(PublishTaskData {
1514 status: TaskStatus::Running,
1515 revision: 1,
1516 output: vec![format!("Running {}...", kind.label())],
1517 })),
1518 }
1519 }
1520
1521 fn status(&self) -> TaskStatus {
1522 self.shared
1523 .lock()
1524 .expect("publish task lock poisoned")
1525 .status
1526 }
1527
1528 fn revision(&self) -> u64 {
1529 self.shared
1530 .lock()
1531 .expect("publish task lock poisoned")
1532 .revision
1533 }
1534
1535 fn output(&self) -> Vec<String> {
1536 self.shared
1537 .lock()
1538 .expect("publish task lock poisoned")
1539 .output
1540 .clone()
1541 }
1542}
1543
1544impl PartialEq for PublishTaskState {
1545 fn eq(&self, other: &Self) -> bool {
1546 self.kind == other.kind && self.revision() == other.revision()
1547 }
1548}
1549
1550#[derive(Clone, Debug)]
1551struct PublishTaskData {
1552 status: TaskStatus,
1553 revision: u64,
1554 output: Vec<String>,
1555}
1556
1557#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1558pub(super) enum TaskStatus {
1559 Running,
1560 Ok,
1561 Failed,
1562}
1563
1564impl TaskStatus {
1565 fn label(self) -> &'static str {
1566 match self {
1567 Self::Running => "running",
1568 Self::Ok => "passed",
1569 Self::Failed => "failed",
1570 }
1571 }
1572}
1573
1574#[fission_reducer(PublishNextStep)]
1575fn publish_next_step(state: &mut PublishUiState) {
1576 state.next_step();
1577}
1578
1579#[fission_reducer(PublishPreviousStep)]
1580fn publish_previous_step(state: &mut PublishUiState) {
1581 state.previous_step();
1582}
1583
1584#[fission_reducer(PublishGoToStep)]
1585fn publish_go_to_step(state: &mut PublishUiState, step: usize) {
1586 state.go_to_step(step);
1587}
1588
1589#[fission_reducer(PublishRefresh)]
1590fn publish_refresh(state: &mut PublishUiState) {
1591 state.start_snapshot_refresh();
1592}
1593
1594#[fission_reducer(PublishToggleTheme)]
1595fn publish_toggle_theme(state: &mut PublishUiState) {
1596 state.theme_mode = match state.theme_mode {
1597 ThemeMode::Light => ThemeMode::Dark,
1598 ThemeMode::Dark => ThemeMode::Light,
1599 };
1600}
1601
1602fn text_input_value(ctx: &ReducerContext<PublishUiState>) -> Option<String> {
1603 ctx.input
1604 .text_change()
1605 .map(|change| change.new_text.clone())
1606}
1607
1608#[fission_reducer(PublishSetTrack)]
1609fn publish_set_track(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1610 let Some(value) = text_input_value(ctx) else {
1611 return;
1612 };
1613 state.track = value;
1614 state.status_message =
1615 "Track updated; refresh preflight to re-check provider readiness.".into();
1616}
1617
1618#[fission_reducer(PublishSetLocales)]
1619fn publish_set_locales(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1620 let Some(value) = text_input_value(ctx) else {
1621 return;
1622 };
1623 state.locales_input = value;
1624 state.status_message =
1625 "Locales updated; refresh preflight to re-check release readiness.".into();
1626}
1627
1628#[fission_reducer(PublishSetPlayJson)]
1629fn publish_set_play_json(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1630 if let Some(value) = text_input_value(ctx) {
1631 state.play_json_path = value;
1632 }
1633}
1634
1635#[fission_reducer(PublishSetAndroidJks)]
1636fn publish_set_android_jks(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1637 if let Some(value) = text_input_value(ctx) {
1638 state.android_jks_path = value;
1639 }
1640}
1641
1642#[fission_reducer(PublishSetAndroidAlias)]
1643fn publish_set_android_alias(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1644 if let Some(value) = text_input_value(ctx) {
1645 state.android_alias = value;
1646 }
1647}
1648
1649#[fission_reducer(PublishSetAndroidPassword)]
1650fn publish_set_android_password(
1651 state: &mut PublishUiState,
1652 ctx: &mut ReducerContext<PublishUiState>,
1653) {
1654 if let Some(value) = text_input_value(ctx) {
1655 state.android_password = value;
1656 }
1657}
1658
1659#[fission_reducer(PublishSetAppStoreKeyPath)]
1660fn publish_set_app_store_key_path(
1661 state: &mut PublishUiState,
1662 ctx: &mut ReducerContext<PublishUiState>,
1663) {
1664 if let Some(value) = text_input_value(ctx) {
1665 state.app_store_key_path = value;
1666 }
1667}
1668
1669#[fission_reducer(PublishSetAppStoreKeyId)]
1670fn publish_set_app_store_key_id(
1671 state: &mut PublishUiState,
1672 ctx: &mut ReducerContext<PublishUiState>,
1673) {
1674 if let Some(value) = text_input_value(ctx) {
1675 state.app_store_key_id = value;
1676 }
1677}
1678
1679#[fission_reducer(PublishSetAppStoreIssuerId)]
1680fn publish_set_app_store_issuer_id(
1681 state: &mut PublishUiState,
1682 ctx: &mut ReducerContext<PublishUiState>,
1683) {
1684 if let Some(value) = text_input_value(ctx) {
1685 state.app_store_issuer_id = value;
1686 }
1687}
1688
1689#[fission_reducer(PublishSetWindowsPfx)]
1690fn publish_set_windows_pfx(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1691 if let Some(value) = text_input_value(ctx) {
1692 state.windows_pfx_path = value;
1693 }
1694}
1695
1696#[fission_reducer(PublishSetWindowsPassword)]
1697fn publish_set_windows_password(
1698 state: &mut PublishUiState,
1699 ctx: &mut ReducerContext<PublishUiState>,
1700) {
1701 if let Some(value) = text_input_value(ctx) {
1702 state.windows_password = value;
1703 }
1704}
1705
1706#[fission_reducer(PublishSetAzureTenant)]
1707fn publish_set_azure_tenant(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1708 if let Some(value) = text_input_value(ctx) {
1709 state.azure_tenant_id = value;
1710 }
1711}
1712
1713#[fission_reducer(PublishSetAzureClient)]
1714fn publish_set_azure_client(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1715 if let Some(value) = text_input_value(ctx) {
1716 state.azure_client_id = value;
1717 }
1718}
1719
1720#[fission_reducer(PublishSetMicrosoftSecret)]
1721fn publish_set_microsoft_secret(
1722 state: &mut PublishUiState,
1723 ctx: &mut ReducerContext<PublishUiState>,
1724) {
1725 if let Some(value) = text_input_value(ctx) {
1726 state.microsoft_secret = value;
1727 }
1728}
1729
1730#[fission_reducer(PublishSetAwsProfile)]
1731fn publish_set_aws_profile(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1732 if let Some(value) = text_input_value(ctx) {
1733 state.aws_profile = value;
1734 }
1735}
1736
1737#[fission_reducer(PublishSetAwsRegion)]
1738fn publish_set_aws_region(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1739 if let Some(value) = text_input_value(ctx) {
1740 state.aws_region = value;
1741 }
1742}
1743
1744#[fission_reducer(PublishSetAwsEndpoint)]
1745fn publish_set_aws_endpoint(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1746 if let Some(value) = text_input_value(ctx) {
1747 state.aws_endpoint = value;
1748 }
1749}
1750
1751#[fission_reducer(PublishSetAwsAccessKey)]
1752fn publish_set_aws_access_key(
1753 state: &mut PublishUiState,
1754 ctx: &mut ReducerContext<PublishUiState>,
1755) {
1756 if let Some(value) = text_input_value(ctx) {
1757 state.aws_access_key_id = value;
1758 }
1759}
1760
1761#[fission_reducer(PublishSetAwsSecretKey)]
1762fn publish_set_aws_secret_key(
1763 state: &mut PublishUiState,
1764 ctx: &mut ReducerContext<PublishUiState>,
1765) {
1766 if let Some(value) = text_input_value(ctx) {
1767 state.aws_secret_access_key = value;
1768 }
1769}
1770
1771#[fission_reducer(PublishSetConfirmation)]
1772fn publish_set_confirmation(state: &mut PublishUiState, ctx: &mut ReducerContext<PublishUiState>) {
1773 if let Some(value) = text_input_value(ctx) {
1774 state.publish_confirmation = value;
1775 }
1776}
1777
1778#[fission_reducer(PublishSaveCredentials)]
1779fn publish_save_credentials(state: &mut PublishUiState) {
1780 state.save_current_credentials();
1781}
1782
1783#[fission_reducer(PublishOpenConfigEditor)]
1784fn publish_open_config_editor(state: &mut PublishUiState, field: String) {
1785 let field = (!field.trim().is_empty()).then_some(field);
1786 state.open_config_editor(field);
1787}
1788
1789#[fission_reducer(PublishCloseConfigEditor)]
1790fn publish_close_config_editor(state: &mut PublishUiState) {
1791 state.config_editor = None;
1792}
1793
1794#[fission_reducer(PublishSetConfigFieldPath)]
1795fn publish_set_config_field_path(
1796 state: &mut PublishUiState,
1797 ctx: &mut ReducerContext<PublishUiState>,
1798) {
1799 let Some(value) = text_input_value(ctx) else {
1800 return;
1801 };
1802 if let Some(editor) = &mut state.config_editor {
1803 editor.field_path = value;
1804 }
1805}
1806
1807#[fission_reducer(PublishSetConfigFieldValue)]
1808fn publish_set_config_field_value(
1809 state: &mut PublishUiState,
1810 ctx: &mut ReducerContext<PublishUiState>,
1811) {
1812 let Some(value) = text_input_value(ctx) else {
1813 return;
1814 };
1815 if let Some(editor) = &mut state.config_editor {
1816 editor.value = value;
1817 }
1818}
1819
1820#[fission_reducer(PublishSelectConfigField)]
1821fn publish_select_config_field(state: &mut PublishUiState, field: String) {
1822 state.select_config_field(field);
1823}
1824
1825#[fission_reducer(PublishApplyConfigField)]
1826fn publish_apply_config_field(state: &mut PublishUiState) {
1827 state.apply_config_editor_field();
1828}
1829
1830#[fission_reducer(PublishOpenFilePicker)]
1831fn publish_open_file_picker(state: &mut PublishUiState, purpose: FilePurpose) {
1832 state.open_file_picker(purpose);
1833}
1834
1835#[fission_reducer(PublishPickFileEntry)]
1836fn publish_pick_file_entry(state: &mut PublishUiState, index: usize) {
1837 state.choose_file_entry(index);
1838}
1839
1840#[fission_reducer(PublishApplyFile)]
1841fn publish_apply_file(state: &mut PublishUiState, action: FileAction) {
1842 state.apply_selected_file(action);
1843}
1844
1845#[fission_reducer(PublishCloseFilePicker)]
1846fn publish_close_file_picker(state: &mut PublishUiState) {
1847 state.file_picker = None;
1848 state.selected_file = None;
1849}
1850
1851#[fission_reducer(PublishStartTask)]
1852fn publish_start_task(state: &mut PublishUiState, kind: PublishTaskKind) {
1853 match kind {
1854 PublishTaskKind::Publish if !state.is_ready_to_publish() => {
1855 state.status_message =
1856 "Publish is locked until checks pass and the app id is typed exactly".to_string();
1857 }
1858 PublishTaskKind::GenerateAndroidKey => state.generate_android_key(),
1859 _ => state.start_cli_task(kind),
1860 }
1861}
1862
1863#[fission_reducer(PublishSkipRequirement)]
1864fn publish_skip_requirement(state: &mut PublishUiState, id: String) {
1865 state.skip_requirement(id);
1866}
1867
1868pub fn default_publish_options(project_dir: PathBuf) -> PublishUiOptions {
1869 let provider = read_project_config(&project_dir)
1870 .ok()
1871 .and_then(|project| {
1872 if project.targets.contains(&Target::Android) {
1873 Some(DistributionProvider::PlayStore)
1874 } else if project.targets.contains(&Target::Ios) {
1875 Some(DistributionProvider::AppStore)
1876 } else if project.targets.contains(&Target::Windows) {
1877 Some(DistributionProvider::MicrosoftStore)
1878 } else {
1879 None
1880 }
1881 })
1882 .unwrap_or(DistributionProvider::PlayStore);
1883 PublishUiOptions {
1884 project_dir,
1885 provider,
1886 target: None,
1887 format: None,
1888 artifact: None,
1889 site: "production".to_string(),
1890 deploy: None,
1891 track: None,
1892 locales: Vec::new(),
1893 screenshot: None,
1894 exit_after_render: false,
1895 width: None,
1896 height: None,
1897 native_file_dialog: false,
1898 }
1899}