diff --git a/Panel/lang/English/modules/cron.php b/Panel/lang/English/modules/cron.php index cdbe71d3..6c6ceca8 100644 --- a/Panel/lang/English/modules/cron.php +++ b/Panel/lang/English/modules/cron.php @@ -56,6 +56,10 @@ define('LANG_server_content_notify_updates_only', 'Notify Updates Only'); define('LANG_server_content_update_all', 'Update All Server Content'); define('LANG_server_content_validate_files', 'Validate Server Content Files'); define('LANG_server_content_backup_before_update', 'Backup Before Server Content Update'); +define('LANG_workshop_update', 'Update Workshop Items'); +define('LANG_workshop_update_and_restart', 'Update Workshop Items And Restart'); +define('LANG_workshop_download_only', 'Download Workshop Items Only'); +define('LANG_workshop_install_pending_on_restart', 'Install Pending Workshop Items On Restart'); define('LANG_safe_restart', 'Safe Restart'); define('LANG_workshop_content', 'Workshop Content'); define('LANG_server_content', 'Server Content'); diff --git a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh index dc366922..6924e4dc 100755 --- a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh +++ b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh @@ -153,13 +153,15 @@ try: workshop_app_id = str(extra.get('workshop_app_id') or manifest.get('workshop_app_id') or '').strip() steam_app_id = str(extra.get('steam_app_id') or manifest.get('steam_app_id') or '').strip() server_root = ensure_under_home(extra.get('server_root') or home_root) - steamcmd_path = resolve_steamcmd(extra.get('steamcmd_path') or '') + steamcmd_path = '' + if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files'): + steamcmd_path = resolve_steamcmd(extra.get('steamcmd_path') or '') post_install_script = str(extra.get('post_install_script') or '').strip() item_details = manifest.get('item_details') or extra.get('item_details') or {} default_install_strategy = str(manifest.get('install_strategy') or extra.get('install_strategy') or 'copy_to_mod_folder').strip() default_download_dir = extra.get('workshop_download_dir') or os.path.join(server_root, 'steamapps', 'workshop', 'content', workshop_app_id or steam_app_id) - action_label = 'Queued' if action in ('install', 'update', 'check_updates') else action + action_label = 'Queued' if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files') else action log(f"action={action} manifest={manifest_path} steam_app_id={steam_app_id or 'n/a'} workshop_app_id={workshop_app_id or 'n/a'}", action_label) for workshop_id in items: @@ -187,7 +189,7 @@ try: download_dir = ensure_under_home(render_template(default_download_dir, template_values)) source_path = os.path.join(download_dir, workshop_id) - if action in ('install', 'update', 'check_updates'): + if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files'): if not workshop_app_id: fail(f"Workshop App ID is missing for Workshop item {workshop_id}.") command = [ @@ -207,7 +209,7 @@ try: if not os.path.isdir(source_path): fail(f"SteamCMD did not create the expected Workshop cache path: {source_path}") - if action != 'check_updates': + if action not in ('check_updates', 'download_only', 'validate_files'): log(f"workshop_id={workshop_id} strategy={install_strategy} install_path={target_path}", 'Extracting/Copying') sync_copy(source_path, target_path) log(f"workshop_id={workshop_id} final_folder_path={target_path}", 'Applying Folder Name') @@ -221,6 +223,10 @@ try: log(f"post_install[{workshop_id}] {line}") if post_result.returncode != 0: fail(f"Post-install script failed for Workshop item {workshop_id} with exit code {post_result.returncode}.") + elif action == 'download_only': + log(f"workshop_id={workshop_id} cached_path={source_path}", 'Downloaded Only') + elif action == 'validate_files': + log(f"workshop_id={workshop_id} cached_path={source_path}", 'Validated') log(f"workshop_id={workshop_id} install_path={target_path}", 'Completed') elif action == 'remove': if os.path.exists(target_path): diff --git a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh index 09d594d2..24e7c5e8 100755 --- a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh +++ b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh @@ -154,13 +154,15 @@ try: workshop_app_id = str(extra.get('workshop_app_id') or manifest.get('workshop_app_id') or '').strip() steam_app_id = str(extra.get('steam_app_id') or manifest.get('steam_app_id') or '').strip() server_root = ensure_under_home(extra.get('server_root') or home_root) - steamcmd_path = resolve_steamcmd(extra.get('steamcmd_path') or '') + steamcmd_path = '' + if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files'): + steamcmd_path = resolve_steamcmd(extra.get('steamcmd_path') or '') post_install_script = str(extra.get('post_install_script') or '').strip() item_details = manifest.get('item_details') or extra.get('item_details') or {} default_install_strategy = str(manifest.get('install_strategy') or extra.get('install_strategy') or 'copy_to_mod_folder').strip() default_download_dir = extra.get('workshop_download_dir') or os.path.join(server_root, 'steamapps', 'workshop', 'content', workshop_app_id or steam_app_id) - action_label = 'Queued' if action in ('install', 'update', 'check_updates') else action + action_label = 'Queued' if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files') else action log(f"action={action} manifest={manifest_path} steam_app_id={steam_app_id or 'n/a'} workshop_app_id={workshop_app_id or 'n/a'}", action_label) for workshop_id in items: @@ -188,7 +190,7 @@ try: download_dir = ensure_under_home(render_template(default_download_dir, template_values)) source_path = os.path.join(download_dir, workshop_id) - if action in ('install', 'update', 'check_updates'): + if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files'): if not workshop_app_id: fail(f"Workshop App ID is missing for Workshop item {workshop_id}.") command = [ @@ -208,7 +210,7 @@ try: if not os.path.isdir(source_path): fail(f"SteamCMD did not create the expected Workshop cache path: {source_path}") - if action != 'check_updates': + if action not in ('check_updates', 'download_only', 'validate_files'): log(f"workshop_id={workshop_id} strategy={install_strategy} install_path={target_path}", 'Extracting/Copying') sync_copy(source_path, target_path) log(f"workshop_id={workshop_id} final_folder_path={target_path}", 'Applying Folder Name') @@ -222,6 +224,10 @@ try: log(f"post_install[{workshop_id}] {line}") if post_result.returncode != 0: fail(f"Post-install script failed for Workshop item {workshop_id} with exit code {post_result.returncode}.") + elif action == 'download_only': + log(f"workshop_id={workshop_id} cached_path={source_path}", 'Downloaded Only') + elif action == 'validate_files': + log(f"workshop_id={workshop_id} cached_path={source_path}", 'Validated') log(f"workshop_id={workshop_id} install_path={target_path}", 'Completed') elif action == 'remove': if os.path.exists(target_path): diff --git a/Panel/modules/addonsmanager/server_content_actions.php b/Panel/modules/addonsmanager/server_content_actions.php index b78308cf..bd8ec1a5 100644 --- a/Panel/modules/addonsmanager/server_content_actions.php +++ b/Panel/modules/addonsmanager/server_content_actions.php @@ -20,11 +20,15 @@ function server_content_get_home_info($home_id) return $db->getGameHome($home_id); } -function server_content_collect_workshop_ids($db, $home_id) +function server_content_collect_workshop_ids($db, $home_id, $pending_only = false) { + $where = "home_id=".(int)$home_id." AND install_state<>'removed'"; + if ($pending_only) { + $where .= " AND pending_action='install_on_restart'"; + } $rows = $db->resultQuery( "SELECT workshop_item_id FROM `".OGP_DB_PREFIX."server_content_workshop` - WHERE home_id=".(int)$home_id." AND install_state<>'removed'" + WHERE {$where}" ); $item_ids = array(); if (is_array($rows)) { @@ -237,7 +241,7 @@ function server_content_install_updates($home_id, $options = array()) if ($workshop_action === '') { $workshop_action = !empty($options['check_only']) ? 'check_updates' : 'update'; } - $workshop_ids = server_content_collect_workshop_ids($db, (int)$home_info['home_id']); + $workshop_ids = server_content_collect_workshop_ids($db, (int)$home_info['home_id'], !empty($options['pending_only'])); $manifest_rows = server_content_collect_manifest_addon_rows($db, (int)$home_info['home_id']); if (empty($workshop_ids) && empty($manifest_rows)) { $result = server_content_result('no_updates', 'No installed server content records were found for this home.', array( @@ -251,12 +255,65 @@ function server_content_install_updates($home_id, $options = array()) return $result; } - if (!empty($workshop_ids) && empty($options['check_only'])) { - scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'installing', null, false, false); + if (!empty($workshop_ids)) { + $server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']); + if ($server_xml === false) { + return server_content_result('failed', 'Unable to load server XML configuration for Workshop update.'); + } + $manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $workshop_ids, array()); + if (empty($manifest_context['workshop_app_id'])) { + return server_content_result('failed', 'Workshop App ID is missing. Configure it in game XML or the Server Content template.'); + } + if (empty($options['check_only'])) { + scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'installing', null, false, false); + } + $error = ''; + $details = array(); + $ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, $workshop_action, $workshop_ids, $error, $manifest_context, $details); + if (!$ok) { + if (empty($options['check_only'])) { + scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'failed', $error, false, false); + } + return server_content_result('failed', $error, $details); + } + if (empty($options['check_only'])) { + $final_state = ($workshop_action === 'download_only') ? 'downloaded' : 'installed'; + scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, $final_state, null, false, true); + if (!empty($options['pending_only'])) { + scm_workshop_set_update_policy($db, (int)$home_info['home_id'], $workshop_ids, 'manual'); + } + if ($workshop_action === 'download_only') { + scm_workshop_set_update_policy($db, (int)$home_info['home_id'], $workshop_ids, 'install_on_restart'); + } + scm_workshop_record_catalog_items($db, (string)$manifest_context['workshop_app_id'], $workshop_ids, $home_info, isset($manifest_context['item_details']) ? $manifest_context['item_details'] : array(), true); + } + server_content_record_installed_content_state($home_info, array( + 'home_id' => (int)$home_info['home_id'], + 'last_action' => (string)$workshop_action, + 'last_result' => 'success', + 'last_manifest' => isset($details['manifest_path']) ? $details['manifest_path'] : '', + 'last_updated_at' => date('Y-m-d H:i:s'), + 'installed_workshop_ids' => $workshop_ids, + )); + if (empty($manifest_rows) || !empty($options['workshop_only'])) { + if (!empty($options['check_only'])) { + return server_content_result('success', 'Workshop update check completed.', array( + 'workshop_items' => count($workshop_ids), + 'manifest_path' => isset($details['manifest_path']) ? $details['manifest_path'] : '', + 'log_path' => isset($details['log_path']) ? $details['log_path'] : '', + )); + } + return server_content_result('success', 'Workshop action completed.', array( + 'workshop_action' => $workshop_action, + 'workshop_items' => count($workshop_ids), + 'manifest_path' => isset($details['manifest_path']) ? $details['manifest_path'] : '', + 'log_path' => isset($details['log_path']) ? $details['log_path'] : '', + )); + } } $manifest_items = array( - 'workshop_item_ids' => $workshop_ids, + 'workshop_item_ids' => array(), 'manifest_addons' => $manifest_rows, ); $manifest_path = server_content_build_manifest($home_info['home_id'], 'server_content', $workshop_action, $manifest_items, $options); @@ -276,9 +333,6 @@ function server_content_install_updates($home_id, $options = array()) return $execute; } - if (!empty($workshop_ids) && empty($options['check_only'])) { - scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'installed', null, false, true); - } server_content_record_installed_content_state($home_info, array( 'home_id' => (int)$home_info['home_id'], 'last_action' => (string)$workshop_action, @@ -447,6 +501,10 @@ function server_content_run_scheduled_action($home_id, $action, $options = array 'server_content_notify_updates_only' => 'server_content_check_updates', 'server_content_validate_files' => 'server_content_update_workshop', 'server_content_backup_before_update' => 'server_content_install_updates', + 'workshop_update' => 'server_content_update_workshop', + 'workshop_update_and_restart' => 'server_content_install_updates_and_restart', + 'workshop_download_only' => 'server_content_update_workshop', + 'workshop_install_pending_on_restart' => 'server_content_install_updates', ); if (!isset($handlers[$action]) || !function_exists($handlers[$action])) { $result = server_content_result('failed', 'Unsupported scheduled server content action.', array( @@ -463,9 +521,22 @@ function server_content_run_scheduled_action($home_id, $action, $options = array if ($action === 'server_content_backup_before_update') { $options['backup_before_update'] = true; } - if ($action === 'server_content_install_updates_and_restart' && !isset($options['safe_restart'])) { + if ($action === 'server_content_install_updates_and_restart' || $action === 'workshop_update_and_restart') { + $options['workshop_action'] = 'update'; + } + if (in_array($action, array('workshop_update', 'workshop_update_and_restart', 'workshop_download_only', 'workshop_install_pending_on_restart'), true)) { + $options['workshop_only'] = true; + } + if (($action === 'server_content_install_updates_and_restart' || $action === 'workshop_update_and_restart') && !isset($options['safe_restart'])) { $options['safe_restart'] = true; } + if ($action === 'workshop_download_only') { + $options['workshop_action'] = 'download_only'; + } + if ($action === 'workshop_install_pending_on_restart') { + $options['workshop_action'] = 'update'; + $options['pending_only'] = true; + } if ($action === 'server_content_notify_updates_only') { $options['notify_only'] = true; $options['check_only'] = true; diff --git a/Panel/modules/addonsmanager/server_content_helpers.php b/Panel/modules/addonsmanager/server_content_helpers.php index 45b2c2dc..b3f8e27c 100644 --- a/Panel/modules/addonsmanager/server_content_helpers.php +++ b/Panel/modules/addonsmanager/server_content_helpers.php @@ -75,6 +75,8 @@ function scm_ensure_workshop_schema($db) 'install_strategy' => "VARCHAR(64) NULL AFTER `install_path`", 'enabled' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `install_strategy`", 'load_order' => "INT NOT NULL DEFAULT 0 AFTER `enabled`", + 'update_policy' => "VARCHAR(64) NOT NULL DEFAULT 'manual' AFTER `load_order`", + 'pending_action' => "VARCHAR(64) NULL AFTER `update_policy`", ); foreach ($workshop_columns as $col => $definition) { $escaped_col = $db->realEscapeSingle($col); @@ -89,6 +91,27 @@ function scm_ensure_workshop_schema($db) } } + $db->query( + "CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."server_content_workshop_catalog` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `workshop_id` VARCHAR(64) NOT NULL, + `app_id` VARCHAR(32) NOT NULL DEFAULT '', + `title` VARCHAR(255) NULL, + `install_count` INT NOT NULL DEFAULT 0, + `first_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_installed` DATETIME NULL, + `last_updated` DATETIME NULL, + `published_date` DATETIME NULL, + `tags` TEXT NULL, + `game_key` VARCHAR(128) NULL, + `local_cache_path` VARCHAR(512) NULL, + UNIQUE KEY `uniq_workshop_app` (`workshop_id`, `app_id`), + KEY `idx_app_id` (`app_id`), + KEY `idx_install_count` (`install_count`), + KEY `idx_last_installed` (`last_installed`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + return $ok; } @@ -132,6 +155,144 @@ function scm_get_workshop_rows($db, $home_id) return is_array($rows) ? $rows : array(); } +function scm_get_workshop_update_policies() +{ + return array( + 'manual' => 'Manual update only', + 'scheduled' => 'Update on scheduled task', + 'update_now' => 'Update immediately', + 'update_and_restart' => 'Update immediately and restart', + 'download_only' => 'Download now, install later', + 'install_on_restart' => 'Install during next restart', + ); +} + +function scm_normalize_workshop_update_policy($policy) +{ + $policy = trim((string)$policy); + $policies = scm_get_workshop_update_policies(); + return isset($policies[$policy]) ? $policy : 'manual'; +} + +function scm_workshop_set_enabled($db, $home_id, array $item_ids, $enabled) +{ + if (empty($item_ids)) { + return false; + } + $escaped_ids = array(); + foreach ($item_ids as $item_id) { + $escaped_ids[] = "'" . $db->realEscapeSingle((string)$item_id) . "'"; + } + return (bool)$db->query( + "UPDATE `".OGP_DB_PREFIX."server_content_workshop` + SET enabled=" . ((int)$enabled ? 1 : 0) . ", updated_at=NOW() + WHERE home_id=".(int)$home_id." AND workshop_item_id IN (".implode(",", $escaped_ids).")" + ); +} + +function scm_workshop_set_update_policy($db, $home_id, array $item_ids, $policy) +{ + if (empty($item_ids)) { + return false; + } + $policy = scm_normalize_workshop_update_policy($policy); + $pending = ''; + if ($policy === 'download_only') { + $pending = 'install_on_restart'; + } + elseif ($policy === 'install_on_restart') { + $pending = 'install_on_restart'; + } + $escaped_ids = array(); + foreach ($item_ids as $item_id) { + $escaped_ids[] = "'" . $db->realEscapeSingle((string)$item_id) . "'"; + } + return (bool)$db->query( + "UPDATE `".OGP_DB_PREFIX."server_content_workshop` + SET update_policy='" . $db->realEscapeSingle($policy) . "', + pending_action=" . ($pending === '' ? "NULL" : "'" . $db->realEscapeSingle($pending) . "'") . ", + updated_at=NOW() + WHERE home_id=".(int)$home_id." AND workshop_item_id IN (".implode(",", $escaped_ids).")" + ); +} + +function scm_workshop_catalog_sort_sql($sort) +{ + $sort = trim((string)$sort); + $allowed = array( + 'name' => 'title ASC, workshop_id ASC', + 'install_count' => 'install_count DESC, last_installed DESC, workshop_id ASC', + 'published_date' => 'published_date DESC, title ASC, workshop_id ASC', + 'last_updated' => 'last_updated DESC, title ASC, workshop_id ASC', + 'last_installed' => 'last_installed DESC, title ASC, workshop_id ASC', + ); + return isset($allowed[$sort]) ? $allowed[$sort] : $allowed['last_installed']; +} + +function scm_get_workshop_catalog_rows($db, $app_id = '', $sort = 'last_installed', $limit = 50) +{ + if (!scm_ensure_workshop_schema($db)) { + return array(); + } + $where = ''; + $app_id = trim((string)$app_id); + if ($app_id !== '' && preg_match('/^[0-9]+$/', $app_id)) { + $where = "WHERE app_id='" . $db->realEscapeSingle($app_id) . "'"; + } + $limit = (int)$limit; + if ($limit <= 0 || $limit > 200) { + $limit = 50; + } + $rows = $db->resultQuery( + "SELECT * FROM `".OGP_DB_PREFIX."server_content_workshop_catalog` + {$where} + ORDER BY " . scm_workshop_catalog_sort_sql($sort) . " + LIMIT {$limit}" + ); + return is_array($rows) ? $rows : array(); +} + +function scm_workshop_record_catalog_items($db, $workshop_app_id, array $item_ids, array $home_info = array(), array $item_details = array(), $mark_update = false) +{ + if (empty($item_ids) || !scm_ensure_workshop_schema($db)) { + return false; + } + $workshop_app_id = preg_match('/^[0-9]+$/', (string)$workshop_app_id) ? (string)$workshop_app_id : ''; + $game_key = isset($home_info['game_key']) ? (string)$home_info['game_key'] : ''; + foreach ($item_ids as $item_id) { + $item_id = (string)$item_id; + if (!preg_match('/^[0-9]+$/', $item_id)) { + continue; + } + $detail = isset($item_details[$item_id]) && is_array($item_details[$item_id]) ? $item_details[$item_id] : array(); + $title = isset($detail['title']) ? (string)$detail['title'] : ''; + $install_path = isset($detail['target_path_resolved']) ? (string)$detail['target_path_resolved'] : ''; + $db->query( + "INSERT INTO `".OGP_DB_PREFIX."server_content_workshop_catalog` + (workshop_id, app_id, title, install_count, first_seen, last_installed, last_updated, game_key, local_cache_path) + VALUES ( + '".$db->realEscapeSingle($item_id)."', + '".$db->realEscapeSingle($workshop_app_id)."', + ".($title === '' ? "NULL" : "'".$db->realEscapeSingle($title)."'").", + 1, + NOW(), + NOW(), + ".($mark_update ? "NOW()" : "NULL").", + ".($game_key === '' ? "NULL" : "'".$db->realEscapeSingle($game_key)."'").", + ".($install_path === '' ? "NULL" : "'".$db->realEscapeSingle($install_path)."'")." + ) + ON DUPLICATE KEY UPDATE + install_count=install_count+1, + title=IF(VALUES(title) IS NULL OR VALUES(title)='', title, VALUES(title)), + last_installed=NOW(), + last_updated=".($mark_update ? "NOW()" : "last_updated").", + game_key=IF(VALUES(game_key) IS NULL OR VALUES(game_key)='', game_key, VALUES(game_key)), + local_cache_path=IF(VALUES(local_cache_path) IS NULL OR VALUES(local_cache_path)='', local_cache_path, VALUES(local_cache_path))" + ); + } + return true; +} + function scm_extract_workshop_item_id($value) { $value = trim((string)$value); diff --git a/Panel/modules/addonsmanager/tests/workshop_helpers_test.php b/Panel/modules/addonsmanager/tests/workshop_helpers_test.php index ef3cce03..db319392 100644 --- a/Panel/modules/addonsmanager/tests/workshop_helpers_test.php +++ b/Panel/modules/addonsmanager/tests/workshop_helpers_test.php @@ -52,6 +52,9 @@ scm_workshop_test_assert(empty($invalid), 'valid mixed Workshop input has no inv $ids = scm_parse_workshop_ids("abc, https://steamcommunity.com/sharedfiles/filedetails/?id=0, 123", $invalid); scm_workshop_test_assert($ids === array('123'), 'keeps valid IDs when invalid values are present'); scm_workshop_test_assert(count($invalid) === 2, 'reports invalid Workshop entries'); +scm_workshop_test_assert(scm_normalize_workshop_update_policy('download_only') === 'download_only', 'accepts download-only Workshop update policy'); +scm_workshop_test_assert(scm_normalize_workshop_update_policy('bad_policy') === 'manual', 'invalid Workshop update policy falls back to manual'); +scm_workshop_test_assert(strpos(scm_workshop_catalog_sort_sql('install_count'), 'install_count DESC') !== false, 'catalog sort supports install count'); $linuxHome = array( 'home_id' => 10, diff --git a/Panel/modules/addonsmanager/workshop_action.php b/Panel/modules/addonsmanager/workshop_action.php index ca358c95..877159f9 100644 --- a/Panel/modules/addonsmanager/workshop_action.php +++ b/Panel/modules/addonsmanager/workshop_action.php @@ -243,7 +243,7 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, return true; } -function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $raw_ids, array $selected_ids, &$message, &$is_error, $addon_id = 0) +function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $raw_ids, array $selected_ids, &$message, &$is_error, $addon_id = 0, array $options = array()) { $message = ''; $is_error = true; @@ -341,6 +341,7 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r $ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'install', $item_ids, $error, $manifest_context, $details); if ($ok) { scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, true, true); + scm_workshop_record_catalog_items($db, $resolved_app_id, $item_ids, $home_info, isset($manifest_context['item_details']) ? $manifest_context['item_details'] : array(), true); scm_workshop_log_action($db, $home_id, $user_id, "install_new ids=".implode(',', $item_ids)." addon_id=".$addon_id." status=success"); $is_error = false; $message = 'Workshop item(s) installed successfully. Manifest: '.scm_h(isset($details['manifest_path']) ? $details['manifest_path'] : '').' Log: '.scm_h(isset($details['log_path']) ? $details['log_path'] : ''); @@ -352,13 +353,39 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r return false; } - if ($action === 'update_selected' || $action === 'remove_selected') { + if ($action === 'enable_selected' || $action === 'disable_selected' || $action === 'save_update_policy') { $item_ids = scm_workshop_filter_existing_ids($db, $home_id, scm_parse_selected_workshop_ids($selected_ids)); if (empty($item_ids)) { $message = 'Select one or more saved Workshop IDs.'; return false; } - $target_action = ($action === 'remove_selected') ? 'remove' : 'update'; + if ($action === 'save_update_policy') { + $policy = isset($options['update_policy']) ? (string)$options['update_policy'] : 'manual'; + if (!scm_workshop_set_update_policy($db, $home_id, $item_ids, $policy)) { + $message = 'Failed to save Workshop update policy.'; + return false; + } + $is_error = false; + $message = 'Workshop update policy saved for selected item(s).'; + return true; + } + $enabled = ($action === 'enable_selected') ? 1 : 0; + if (!scm_workshop_set_enabled($db, $home_id, $item_ids, $enabled)) { + $message = 'Failed to update Workshop enabled state.'; + return false; + } + $is_error = false; + $message = $enabled ? 'Selected Workshop item(s) enabled.' : 'Selected Workshop item(s) disabled.'; + return true; + } + + if ($action === 'update_selected' || $action === 'remove_selected' || $action === 'download_selected') { + $item_ids = scm_workshop_filter_existing_ids($db, $home_id, scm_parse_selected_workshop_ids($selected_ids)); + if (empty($item_ids)) { + $message = 'Select one or more saved Workshop IDs.'; + return false; + } + $target_action = ($action === 'remove_selected') ? 'remove' : (($action === 'download_selected') ? 'download_only' : 'update'); $manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $item_ids, $template); scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installing', null, false, false); $error = ''; @@ -367,12 +394,24 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r if ($ok) { if ($target_action === 'remove') { scm_workshop_update_rows_state($db, $home_id, $item_ids, 'removed', null, false, true); + } elseif ($target_action === 'download_only') { + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'downloaded', null, false, true); + scm_workshop_set_update_policy($db, $home_id, $item_ids, 'install_on_restart'); + scm_workshop_record_catalog_items($db, isset($manifest_context['workshop_app_id']) ? (string)$manifest_context['workshop_app_id'] : '', $item_ids, $home_info, isset($manifest_context['item_details']) ? $manifest_context['item_details'] : array(), true); } else { scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, false, true); + scm_workshop_record_catalog_items($db, isset($manifest_context['workshop_app_id']) ? (string)$manifest_context['workshop_app_id'] : '', $item_ids, $home_info, isset($manifest_context['item_details']) ? $manifest_context['item_details'] : array(), true); } scm_workshop_log_action($db, $home_id, $user_id, $action." ids=".implode(',', $item_ids)." addon_id=".$addon_id." status=success"); $is_error = false; - $message = (($target_action === 'remove') ? 'Selected Workshop item(s) removed.' : 'Selected Workshop item(s) updated successfully.') . ' Log: ' . scm_h(isset($details['log_path']) ? $details['log_path'] : ''); + if ($target_action === 'remove') { + $message = 'Selected Workshop item(s) removed.'; + } elseif ($target_action === 'download_only') { + $message = 'Selected Workshop item(s) downloaded and marked for install on next restart.'; + } else { + $message = 'Selected Workshop item(s) updated successfully.'; + } + $message .= ' Log: ' . scm_h(isset($details['log_path']) ? $details['log_path'] : ''); return true; } scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false); @@ -404,6 +443,7 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r $ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'update', $item_ids, $error, $manifest_context, $details); if ($ok) { scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, false, true); + scm_workshop_record_catalog_items($db, isset($manifest_context['workshop_app_id']) ? (string)$manifest_context['workshop_app_id'] : '', $item_ids, $home_info, isset($manifest_context['item_details']) ? $manifest_context['item_details'] : array(), true); scm_workshop_log_action($db, $home_id, $user_id, "update_all ids=".implode(',', $item_ids)." addon_id=".$addon_id." status=success"); $is_error = false; $message = 'All saved Workshop item(s) updated successfully. Log: ' . scm_h(isset($details['log_path']) ? $details['log_path'] : ''); diff --git a/Panel/modules/addonsmanager/workshop_content.php b/Panel/modules/addonsmanager/workshop_content.php index 593ba7dd..2eabad0a 100644 --- a/Panel/modules/addonsmanager/workshop_content.php +++ b/Panel/modules/addonsmanager/workshop_content.php @@ -56,6 +56,7 @@ function exec_ogp_module() { $message = ''; $is_error = false; $entered_ids = ''; + $catalog_sort = isset($_REQUEST['catalog_sort']) ? (string)$_REQUEST['catalog_sort'] : 'last_installed'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $posted_home_id = isset($_POST['home_id']) ? (int)$_POST['home_id'] : 0; @@ -64,6 +65,9 @@ function exec_ogp_module() { $selected_ids = isset($_POST['selected_ids']) ? $_POST['selected_ids'] : array(); $action = isset($_POST['workshop_action']) ? (string)$_POST['workshop_action'] : ''; $posted_addon_id = isset($_POST['addon_id']) ? (int)$_POST['addon_id'] : 0; + $options = array( + 'update_policy' => isset($_POST['update_policy']) ? (string)$_POST['update_policy'] : 'manual', + ); if ($posted_home_id !== $home_id) { $is_error = true; @@ -74,11 +78,14 @@ function exec_ogp_module() { $message = 'Invalid CSRF token for workshop action.'; } else { - scm_workshop_handle_action($db, $home_info, $user_id, $action, $entered_ids, (array)$selected_ids, $message, $is_error, $posted_addon_id > 0 ? $posted_addon_id : $addon_id); + scm_workshop_handle_action($db, $home_info, $user_id, $action, $entered_ids, (array)$selected_ids, $message, $is_error, $posted_addon_id > 0 ? $posted_addon_id : $addon_id, $options); } } $rows = scm_get_workshop_rows($db, $home_id); + $server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']); + $catalog_app_id = ($server_xml !== false) ? scm_extract_workshop_app_id($server_xml) : ''; + $catalog_rows = scm_get_workshop_catalog_rows($db, $catalog_app_id, $catalog_sort, 50); $csrf_token = scm_get_csrf_token(); echo "

Workshop Mods: " . scm_h($home_info['home_name']) . "

"; @@ -135,6 +142,7 @@ function exec_ogp_module() { Title Enabled Order + Update Policy State Install Path Last Installed @@ -142,7 +150,7 @@ function exec_ogp_module() { Last Error - No Workshop items saved for this server yet. + No Workshop items saved for this server yet. @@ -151,6 +159,7 @@ function exec_ogp_module() { + @@ -163,13 +172,51 @@ function exec_ogp_module() {
+ + + + +
+ +
+

Known Workshop Items

+

These are Workshop items previously installed through Server Content Manager. Metadata is optional; direct ID or URL install remains available even when Steam metadata has not been fetched yet.

+ + + + + + + + + + + + + + + + + + + + + + + +
Workshop IDNameInstall CountPublishedLast UpdatedLast Installed
No known Workshop items have been installed for this app yet.
+
diff --git a/Panel/modules/cron/shared_cron_functions.php b/Panel/modules/cron/shared_cron_functions.php index e07df7cc..9a21901c 100644 --- a/Panel/modules/cron/shared_cron_functions.php +++ b/Panel/modules/cron/shared_cron_functions.php @@ -120,6 +120,10 @@ function get_server_content_scheduled_actions() { 'server_content_update_all', 'server_content_validate_files', 'server_content_backup_before_update', + 'workshop_update', + 'workshop_update_and_restart', + 'workshop_download_only', + 'workshop_install_pending_on_restart', ); } @@ -131,11 +135,20 @@ function build_cron_scheduler_command($panelURL, $token, $game_home, $action) { if(in_array($action, get_server_content_scheduled_actions())) { $options = array('triggered_by' => 'scheduler'); - if($action == 'server_content_install_updates_and_restart') + if($action == 'server_content_install_updates_and_restart' || $action == 'workshop_update_and_restart') { $options['safe_restart'] = true; $options['restart_delay_seconds'] = 60; } + if($action == 'workshop_download_only') + { + $options['workshop_action'] = 'download_only'; + } + if($action == 'workshop_install_pending_on_restart') + { + $options['workshop_action'] = 'update'; + $options['pending_only'] = true; + } $options_json = urlencode(json_encode($options)); return "wget -qO- \"{$panelURL}/ogp_api.php?server_content/run_scheduled_action&token={$token}&home_id={$home_id}&action={$action}&options={$options_json}\" --no-check-certificate > /dev/null 2>&1"; } diff --git a/docs/architecture/API_REFERENCE.md b/docs/architecture/API_REFERENCE.md index 9c02204b..68b01482 100644 --- a/docs/architecture/API_REFERENCE.md +++ b/docs/architecture/API_REFERENCE.md @@ -60,7 +60,7 @@ Primary categories: | updates | `steam_cmd`, `component_update`, `stop_update` | | system | `exec`, `sudo_exec`, `rebootnow`, `what_os`, `discover_ips`, `mon_stats` | | scheduler | `scheduler_add_task`, `scheduler_edit_task`, `scheduler_del_task`, `scheduler_list_tasks` | -| content | `steam_workshop`, `get_workshop_mods_info` | +| content | `writefile` + `exec` manifest/script orchestration; legacy `steam_workshop`, `get_workshop_mods_info` | See the full command table in: @@ -82,7 +82,7 @@ Most Panel modules do not build XML-RPC directly. They call `OGPRemoteLibrary`. | `remote_query()` | `remote_query` | `gamemanager`, dashboards | | `component_update()` | `component_update` | update/admin pages | | `scheduler_*()` | `scheduler_*` | `cron` | -| `steam_workshop()` | `steam_workshop` | legacy workshop path | +| `steam_workshop()` | `steam_workshop` | legacy workshop path only | ## External Panel API @@ -207,7 +207,25 @@ Common manifest fields: - `items` - `options` -For Workshop-specific manifests, the broader fields documented in `WORKSHOP_SYSTEM.md` also apply, including app IDs, install strategy, and target paths. +For Workshop-specific manifests, the broader fields documented in `WORKSHOP_SYSTEM.md` also apply, including app IDs, install strategy, update policy, pending action, and target paths. + +Current Workshop actions: + +- `install` +- `update` +- `check_updates` +- `download_only` +- `validate_files` +- `remove` + +Current Scheduler/API action aliases: + +- `workshop_update` +- `workshop_update_and_restart` +- `workshop_download_only` +- `workshop_install_pending_on_restart` + +The legacy `steam_workshop/install` API route and agent `steam_workshop` RPC remain compatibility-only. New user-facing Workshop work should use `addonsmanager` / Server Content Manager. ## Status Contract diff --git a/docs/architecture/PANEL_AGENT_COMMANDS.md b/docs/architecture/PANEL_AGENT_COMMANDS.md index 48b2d202..fcb98716 100644 --- a/docs/architecture/PANEL_AGENT_COMMANDS.md +++ b/docs/architecture/PANEL_AGENT_COMMANDS.md @@ -103,7 +103,9 @@ Current preferred implementation path: - `addonsmanager` stages a manifest and helper script through `writefile` - it executes the helper through `exec` +- it records per-server items and policies in Panel database tables - it uses `steam_workshop` only as legacy compatibility, not as the primary workflow +- no new Workshop-specific business logic should be added to agents for the current design ## Shell And System Commands @@ -172,6 +174,10 @@ The built-in action names handled by the Panel-generated API URLs are: - `server_content_update_all` - `server_content_validate_files` - `server_content_backup_before_update` +- `workshop_update` +- `workshop_update_and_restart` +- `workshop_download_only` +- `workshop_install_pending_on_restart` ## Panel Wrapper Map diff --git a/docs/decisions/0004-workshop-system.md b/docs/decisions/0004-workshop-system.md index a7538698..fdcff73f 100644 --- a/docs/decisions/0004-workshop-system.md +++ b/docs/decisions/0004-workshop-system.md @@ -2,7 +2,7 @@ ## Status -Accepted, Phase 1 implementation active +Accepted, Panel-side orchestration active ## Decision @@ -10,6 +10,8 @@ Accepted, Phase 1 implementation active Phase 1 implements this decision by routing the user-facing Workshop install flow through `addonsmanager/workshop_content.php` and suppressing the standalone `steam_workshop` monitor button. +The current implementation keeps Workshop business logic in the Panel. Agents are generic executors: the Panel writes manifests, stages scripts, invokes `exec`, reads logs/results, and updates database state. New first-class Workshop subsystems should not be added to `Agent-Windows` or `Agent_Linux` unless a future decision explicitly changes this. + ## Reasoning - `addonsmanager` already has the richer schema and more complete product direction. @@ -31,8 +33,12 @@ Phase 1 implements this decision by routing the user-facing Workshop install flo - Workshop input accepts numeric IDs or Steam URLs, then stores numeric IDs only. - Manifests are written under the server home in `gsp_server_content`. - Bundled Linux/Cygwin scripts are copied from the Panel module to an agent-managed folder under the server home before execution. +- These scripts are Panel-owned deployment artifacts, not persistent agent features. - Default script names are treated as bundled handlers, not as existing agent paths. - Missing custom scripts fall back to bundled handlers and log the fallback. +- Known Workshop items are cataloged in `server_content_workshop_catalog`. +- Per-server item state, enablement, load order, update policy, and pending action are stored in `server_content_workshop`. +- Scheduler integrates via `workshop_update`, `workshop_update_and_restart`, `workshop_download_only`, and `workshop_install_pending_on_restart`. - Generic content installs under `{SERVER_ROOT}/workshop/{MOD_FOLDER}` by default. - DayZ/Arma-style installs default to `@` folders and copy `.bikey` files into `keys` when present. - Startup parameter generation remains a later phase. @@ -40,6 +46,21 @@ Phase 1 implements this decision by routing the user-facing Workshop install flo - The Panel helpers read `workshop_support` first, then tolerate older direct tags only as compatibility fallbacks. - Arma 3 Linux and Windows configs declare Workshop app ID `107410` through `workshop_support`. +## Reference Module Findings + +The old `reference/Module-Steam_Workshop` module stored per-game profile XML under its own module folder, required admin-managed Workshop mod lists for some workflows, called dedicated agent RPCs such as `steam_workshop`, and used regex/profile rules to mutate game config values. Useful lessons retained: + +- Customers should be able to paste one or more Workshop IDs or URLs. +- The Panel should store per-server installed Workshop rows. +- SteamCMD command generation should be controlled by admin/game configuration, not customer shell text. + +Rejected legacy behavior: + +- making `steam_workshop` the primary user module +- requiring admins to pre-enter every Workshop item ID +- mutating base game XML per customer install +- adding new Workshop-specific agent business logic + ## Validation Current validation commands: diff --git a/docs/features/SCHEDULER_ACTIONS.md b/docs/features/SCHEDULER_ACTIONS.md index dfe64f60..37400c8e 100644 --- a/docs/features/SCHEDULER_ACTIONS.md +++ b/docs/features/SCHEDULER_ACTIONS.md @@ -40,6 +40,10 @@ The agent stores the cron entry and executes it locally. | `server_content_update_all` | same | same | `cron`, `addonsmanager` | aggregate update flow | | `server_content_validate_files` | same | same | `cron`, `addonsmanager` | validation flow | | `server_content_backup_before_update` | same | same | `cron`, `addonsmanager`, backup-related helpers | backup hook before content update | +| `workshop_update` | same | same | `cron`, `addonsmanager` | Server Content Manager Workshop update flow | +| `workshop_update_and_restart` | same | same | `cron`, `addonsmanager`, `gamemanager` | Workshop update plus safe restart request | +| `workshop_download_only` | same | same | `cron`, `addonsmanager` | SteamCMD download/cache without copying into live mod folders | +| `workshop_install_pending_on_restart` | same | same | `cron`, `addonsmanager` | Install Workshop items marked `pending_action=install_on_restart` | ## Agent Scheduler RPCs diff --git a/docs/features/SCHEDULER_SYSTEM.md b/docs/features/SCHEDULER_SYSTEM.md index 16069eac..c48de46f 100644 --- a/docs/features/SCHEDULER_SYSTEM.md +++ b/docs/features/SCHEDULER_SYSTEM.md @@ -33,3 +33,35 @@ Important references: - clear logs and results - no customer raw shell commands by default +## Server Content / Workshop Actions + +The Scheduler must use Server Content Manager actions for Workshop automation. Do not build a separate Workshop scheduler. + +Current Server Content actions: + +| Action Key | Purpose | +|---|---| +| `server_content_check_updates` | Check content update state. | +| `server_content_check_workshop_updates` | Check Workshop update state. | +| `server_content_install_updates_if_stopped` | Install only when the server is stopped. | +| `server_content_install_updates_next_restart` | Queue updates for the next restart. | +| `server_content_install_updates_now` | Install available updates immediately. | +| `server_content_install_updates_and_restart` | Install updates, wait, then restart. | +| `server_content_update_all` | Update all server content records. | +| `server_content_validate_files` | Validate server content files. | +| `server_content_backup_before_update` | Backup before applying updates. | + +Workshop-specific aliases: + +| Action Key | Purpose | +|---|---| +| `workshop_update` | Update installed Workshop items through Server Content Manager. | +| `workshop_update_and_restart` | Update Workshop items and request a safe restart. | +| `workshop_download_only` | Download/cache Workshop items without installing into live folders. | +| `workshop_install_pending_on_restart` | Install items marked as pending during the restart/maintenance window. | + +Implementation references: + +- `Panel/modules/cron/shared_cron_functions.php` +- `Panel/modules/addonsmanager/server_content_actions.php` +- `Panel/modules/addonsmanager/workshop_action.php` diff --git a/docs/features/WORKSHOP_PHASE1_IMPLEMENTATION.md b/docs/features/WORKSHOP_PHASE1_IMPLEMENTATION.md index 591e8f62..6a4efc2a 100644 --- a/docs/features/WORKSHOP_PHASE1_IMPLEMENTATION.md +++ b/docs/features/WORKSHOP_PHASE1_IMPLEMENTATION.md @@ -26,7 +26,8 @@ Active workflow: 3. Open the `Steam Workshop Mods` category. 4. Paste one or more Steam Workshop URLs or numeric Workshop IDs. 5. Click `Install / Queue`. -6. The Panel validates input, stores numeric Workshop IDs, writes a manifest, syncs the install script, executes it through the agent, and shows the result. +6. The Panel validates input, stores numeric Workshop IDs, writes a manifest, syncs the install script, executes it through the agent's existing `exec` primitive, and shows the result. +7. Installed items can be enabled/disabled, updated, removed, downloaded without immediate install, assigned an update policy, and later used by Scheduler actions. Accepted input examples: @@ -57,7 +58,7 @@ The admin template defines capability and policy. The customer supplies only Wor - or `{SERVER_HOME}/gsp_server_content/scripts/workshop/generic_steam_workshop_windows_cygwin.sh` 6. The agent runs: - `bash