diff --git a/code/modules/modular_computers/file_system/programs/secureye.dm b/code/modules/modular_computers/file_system/programs/secureye.dm
new file mode 100644
index 00000000000..78e72640edd
--- /dev/null
+++ b/code/modules/modular_computers/file_system/programs/secureye.dm
@@ -0,0 +1,195 @@
+#define DEFAULT_MAP_SIZE 15
+
+/datum/computer_file/program/secureye
+ filename = "secureye"
+ filedesc = "SecurEye"
+ ui_header = "borg_mon.gif"
+ program_icon_state = "generic"
+ extended_desc = "This program allows access to standard security camera networks."
+ requires_ntnet = TRUE
+ transfer_access = ACCESS_SECURITY
+ usage_flags = PROGRAM_CONSOLE | PROGRAM_LAPTOP
+ size = 5
+ tgui_id = "NtosSecurEye"
+ program_icon = "eye"
+
+ var/list/network = list("ss13")
+ var/obj/machinery/camera/active_camera
+ /// The turf where the camera was last updated.
+ var/turf/last_camera_turf
+ var/list/concurrent_users = list()
+
+ // Stuff needed to render the map
+ var/map_name
+ var/obj/screen/map_view/cam_screen
+ /// All the plane masters that need to be applied.
+ var/list/cam_plane_masters
+ var/obj/screen/background/cam_background
+
+/datum/computer_file/program/secureye/New()
+ . = ..()
+ // Map name has to start and end with an A-Z character,
+ // and definitely NOT with a square bracket or even a number.
+ map_name = "camera_console_[REF(src)]_map"
+ // Convert networks to lowercase
+ for(var/i in network)
+ network -= i
+ network += lowertext(i)
+ // Initialize map objects
+ cam_screen = new
+ cam_screen.name = "screen"
+ cam_screen.assigned_map = map_name
+ cam_screen.del_on_map_removal = FALSE
+ cam_screen.screen_loc = "[map_name]:1,1"
+ cam_plane_masters = list()
+ for(var/plane in subtypesof(/obj/screen/plane_master))
+ var/obj/screen/instance = new plane()
+ instance.assigned_map = map_name
+ instance.del_on_map_removal = FALSE
+ instance.screen_loc = "[map_name]:CENTER"
+ cam_plane_masters += instance
+ cam_background = new
+ cam_background.assigned_map = map_name
+ cam_background.del_on_map_removal = FALSE
+
+/datum/computer_file/program/secureye/Destroy()
+ qdel(cam_screen)
+ QDEL_LIST(cam_plane_masters)
+ qdel(cam_background)
+ return ..()
+
+/datum/computer_file/program/secureye/ui_interact(mob/user, datum/tgui/ui)
+ // Update UI
+ ui = SStgui.try_update_ui(user, src, ui)
+
+ // Update the camera, showing static if necessary and updating data if the location has moved.
+ update_active_camera_screen()
+
+ if(!ui)
+ var/user_ref = REF(user)
+ var/is_living = isliving(user)
+ // Ghosts shouldn't count towards concurrent users, which produces
+ // an audible terminal_on click.
+ if(is_living)
+ concurrent_users += user_ref
+ // Register map objects
+ user.client.register_map_obj(cam_screen)
+ for(var/plane in cam_plane_masters)
+ user.client.register_map_obj(plane)
+ user.client.register_map_obj(cam_background)
+ return ..()
+
+/datum/computer_file/program/secureye/ui_data()
+ var/list/data = get_header_data()
+ data["network"] = network
+ data["activeCamera"] = null
+ if(active_camera)
+ data["activeCamera"] = list(
+ name = active_camera.c_tag,
+ status = active_camera.status,
+ )
+ return data
+
+/datum/computer_file/program/secureye/ui_static_data()
+ var/list/data = list()
+ data["mapRef"] = map_name
+ var/list/cameras = get_available_cameras()
+ data["cameras"] = list()
+ for(var/i in cameras)
+ var/obj/machinery/camera/C = cameras[i]
+ data["cameras"] += list(list(
+ name = C.c_tag,
+ ))
+
+ return data
+
+/datum/computer_file/program/secureye/ui_act(action, params)
+ . = ..()
+ if(.)
+ return
+
+ if(action == "switch_camera")
+ var/c_tag = params["name"]
+ var/list/cameras = get_available_cameras()
+ var/obj/machinery/camera/selected_camera = cameras[c_tag]
+ active_camera = selected_camera
+ playsound(src, get_sfx("terminal_type"), 25, FALSE)
+
+ if(!selected_camera)
+ return TRUE
+
+ update_active_camera_screen()
+
+ return TRUE
+
+/datum/computer_file/program/secureye/ui_close(mob/user)
+ . = ..()
+ var/user_ref = REF(user)
+ var/is_living = isliving(user)
+ // Living creature or not, we remove you anyway.
+ concurrent_users -= user_ref
+ // Unregister map objects
+ user.client.clear_map(map_name)
+ // Turn off the console
+ if(length(concurrent_users) == 0 && is_living)
+ active_camera = null
+ playsound(src, 'sound/machines/terminal_off.ogg', 25, FALSE)
+
+/datum/computer_file/program/secureye/proc/update_active_camera_screen()
+ // Show static if can't use the camera
+ if(!active_camera?.can_use())
+ show_camera_static()
+ return
+
+ var/list/visible_turfs = list()
+
+ // Is this camera located in or attached to a living thing? If so, assume the camera's loc is the living thing.
+ var/cam_location = isliving(active_camera.loc) ? active_camera.loc : active_camera
+
+ // If we're not forcing an update for some reason and the cameras are in the same location,
+ // we don't need to update anything.
+ // Most security cameras will end here as they're not moving.
+ var/newturf = get_turf(cam_location)
+ if(last_camera_turf == newturf)
+ return
+
+ // Cameras that get here are moving, and are likely attached to some moving atom such as cyborgs.
+ last_camera_turf = get_turf(cam_location)
+
+ var/list/visible_things = active_camera.isXRay() ? range(active_camera.view_range, cam_location) : view(active_camera.view_range, cam_location)
+
+ for(var/turf/visible_turf in visible_things)
+ visible_turfs += visible_turf
+
+ var/list/bbox = get_bbox_of_atoms(visible_turfs)
+ var/size_x = bbox[3] - bbox[1] + 1
+ var/size_y = bbox[4] - bbox[2] + 1
+
+ cam_screen.vis_contents = visible_turfs
+ cam_background.icon_state = "clear"
+ cam_background.fill_rect(1, 1, size_x, size_y)
+
+/datum/computer_file/program/secureye/proc/show_camera_static()
+ cam_screen.vis_contents.Cut()
+ cam_background.icon_state = "scanline2"
+ cam_background.fill_rect(1, 1, DEFAULT_MAP_SIZE, DEFAULT_MAP_SIZE)
+
+// Returns the list of cameras accessible from this computer
+/datum/computer_file/program/secureye/proc/get_available_cameras()
+ var/list/L = list()
+ for (var/obj/machinery/camera/cam in GLOB.cameranet.cameras)
+ if(!is_station_level(cam.z))//Only show station cameras.
+ continue
+ L.Add(cam)
+ var/list/camlist = list()
+ for(var/obj/machinery/camera/cam in L)
+ if(!cam.network)
+ stack_trace("Camera in a cameranet has no camera network")
+ continue
+ if(!(islist(cam.network)))
+ stack_trace("Camera in a cameranet has a non-list camera network")
+ continue
+ var/list/tempnetwork = cam.network & network
+ if(tempnetwork.len)
+ camlist["[cam.c_tag]"] = cam
+ return camlist
diff --git a/tgstation.dme b/tgstation.dme
index 573098d8063..a4ef909bccc 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -2565,6 +2565,7 @@
#include "code\modules\modular_computers\file_system\programs\radar.dm"
#include "code\modules\modular_computers\file_system\programs\robocontrol.dm"
#include "code\modules\modular_computers\file_system\programs\robotact.dm"
+#include "code\modules\modular_computers\file_system\programs\secureye.dm"
#include "code\modules\modular_computers\file_system\programs\sm_monitor.dm"
#include "code\modules\modular_computers\file_system\programs\antagonist\contract_uplink.dm"
#include "code\modules\modular_computers\file_system\programs\antagonist\dos.dm"
diff --git a/tgui/packages/tgui/interfaces/CameraConsole.js b/tgui/packages/tgui/interfaces/CameraConsole.js
index fefa3981db3..39a70f64eca 100644
--- a/tgui/packages/tgui/interfaces/CameraConsole.js
+++ b/tgui/packages/tgui/interfaces/CameraConsole.js
@@ -4,14 +4,14 @@ import { classes } from 'common/react';
import { createSearch } from 'common/string';
import { Fragment } from 'inferno';
import { useBackend, useLocalState } from '../backend';
-import { Button, ByondUi, Input, Section } from '../components';
+import { Button, ByondUi, Input, Section, Flex } from '../components';
import { Window } from '../layouts';
/**
* Returns previous and next camera names relative to the currently
* active camera.
*/
-const prevNextCamera = (cameras, activeCamera) => {
+export const prevNextCamera = (cameras, activeCamera) => {
if (!activeCamera) {
return [];
}
@@ -29,7 +29,7 @@ const prevNextCamera = (cameras, activeCamera) => {
*
* Filters cameras, applies search terms and sorts the alphabetically.
*/
-const selectCameras = (cameras, searchText = '') => {
+export const selectCameras = (cameras, searchText = '') => {
const testSearch = createSearch(searchText, camera => camera.name);
return flow([
// Null camera filter
@@ -100,36 +100,45 @@ export const CameraConsoleContent = (props, context) => {
const { activeCamera } = data;
const cameras = selectCameras(data.cameras, searchText);
return (
- "+e+"
/gi,"\n").replace(/<\/?[a-z0-9-_]+[^>]*>/gi,"").replace(/&(nbsp|amp|quot|lt|gt|apos);/g,(function(e,n){return t[n]})).replace(/?([0-9]+);/gi,(function(e,t){var n=parseInt(t,10);return String.fromCharCode(n)})).replace(/?([0-9a-f]+);/gi,(function(e,t){var n=parseInt(t,16);return String.fromCharCode(n)}))};t.buildQueryString=function(e){return Object.keys(e).map((function(t){return encodeURIComponent(t)+"="+encodeURIComponent(e[t])})).join("&")}},function(e,t,n){"use strict";t.__esModule=!0,t.Box=t.computeBoxClassName=t.computeBoxProps=t.halfUnit=t.unit=void 0;var r=n(6),o=n(0),i=n(465),a=n(42);var u=function(e){return"string"==typeof e?e.endsWith("px")&&!Byond.IS_LTE_IE8?parseFloat(e)/12+"rem":e:"number"==typeof e?Byond.IS_LTE_IE8?12*e+"px":e+"rem":void 0};t.unit=u;var c=function(e){return"string"==typeof e?u(e):"number"==typeof e?u(.5*e):void 0};t.halfUnit=c;var s=function(e){return"string"==typeof e&&a.CSS_COLORS.includes(e)},l=function(e){return function(t,n){"number"!=typeof n&&"string"!=typeof n||(t[e]=n)}},f=function(e,t){return function(n,r){"number"!=typeof r&&"string"!=typeof r||(n[e]=t(r))}},d=function(e,t){return function(n,r){r&&(n[e]=t)}},p=function(e,t,n){return function(r,o){if("number"==typeof o||"string"==typeof o)for(var i=0;ic||n!=n?l*Infinity:l*n}},function(e,t,n){"use strict";var r=n(4),o=Math.hypot,i=Math.abs,a=Math.sqrt;r({target:"Math",stat:!0,forced:!!o&&o(Infinity,NaN)!==Infinity},{hypot:function(e,t){for(var n,r,o=0,u=0,c=arguments.length,s=0;u
\n":"'+(n?e:ee(e,!0))+"
\n"}return e}(),t.blockquote=function(){function e(e){return""+(n?e:ee(e,!0))+"\n"+e+"
\n"}return e}(),t.html=function(){function e(e){return e}return e}(),t.heading=function(){function e(e,t,n,r){return this.options.headerIds?"
\n":"
\n"}return e}(),t.list=function(){function e(e,t,n){var r=t?"ol":"ul";return"<"+r+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+""+r+">\n"}return e}(),t.listitem=function(){function e(e){return"\n\n"+e+"\n"+t+"
\n"}return e}(),t.tablerow=function(){function e(e){return"\n"+e+" \n"}return e}(),t.tablecell=function(){function e(e,t){var n=t.header?"th":"td";return(t.align?"<"+n+' align="'+t.align+'">':"<"+n+">")+e+""+n+">\n"}return e}(),t.strong=function(){function e(e){return""+e+""}return e}(),t.em=function(){function e(e){return""+e+""}return e}(),t.codespan=function(){function e(e){return""+e+""}return e}(),t.br=function(){function e(){return this.options.xhtml?"
":"
"}return e}(),t.del=function(){function e(e){return""+e+""}return e}(),t.link=function(){function e(e,t,n){if(null===(e=J(this.options.sanitize,this.options.baseUrl,e)))return n;var r='"+n+""}return e}(),t.image=function(){function e(e,t,n){if(null===(e=J(this.options.sanitize,this.options.baseUrl,e)))return n;var r='":">"}return e}(),t.text=function(){function e(e){return e}return e}(),e}(),ne=function(){function e(){}var t=e.prototype;return t.strong=function(){function e(e){return e}return e}(),t.em=function(){function e(e){return e}return e}(),t.codespan=function(){function e(e){return e}return e}(),t.del=function(){function e(e){return e}return e}(),t.html=function(){function e(e){return e}return e}(),t.text=function(){function e(e){return e}return e}(),t.link=function(){function e(e,t,n){return""+n}return e}(),t.image=function(){function e(e,t,n){return""+n}return e}(),t.br=function(){function e(){return""}return e}(),e}(),re=function(){function e(){this.seen={}}return e.prototype.slug=function(){function e(e){var t=e.toLowerCase().trim().replace(/<[!\/a-z].*?>/gi,"").replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g,"").replace(/\s/g,"-");if(this.seen.hasOwnProperty(t)){var n=t;do{this.seen[n]++,t=n+"-"+this.seen[n]}while(this.seen.hasOwnProperty(t))}return this.seen[t]=0,t}return e}(),e}(),oe=a.defaults,ie=I.unescape,ae=function(){function e(e){this.options=e||oe,this.options.renderer=this.options.renderer||new te,this.renderer=this.options.renderer,this.renderer.options=this.options,this.textRenderer=new ne,this.slugger=new re}e.parse=function(){function t(t,n){return new e(n).parse(t)}return t}();var t=e.prototype;return t.parse=function(){function e(e,t){void 0===t&&(t=!0);var n,r,o,i,a,u,c,s,l,f,d,p,h,v,g,m,y,b,x="",w=e.length;for(n=0;n
"+se(c.message+"",!0)+"";throw c}}return pe.options=pe.setOptions=function(e){return ue(pe.defaults,e),fe(pe.defaults),pe},pe.getDefaults=le,pe.defaults=de,pe.use=function(e){var t=ue({},e);if(e.renderer&&function(){var n=pe.defaults.renderer||new te,r=function(){function t(t){var r=n[t];n[t]=function(){for(var o=arguments.length,i=new Array(o),a=0;a
'+(n?e:ee(e,!0))+"\n":""+(n?e:ee(e,!0))+"\n"}return e}(),t.blockquote=function(){function e(e){return"\n"+e+"\n"}return e}(),t.html=function(){function e(e){return e}return e}(),t.heading=function(){function e(e,t,n,r){return this.options.headerIds?"
"+e+"
\n"}return e}(),t.table=function(){function e(e,t){return t&&(t=""+t+""),""+e+""}return e}(),t.br=function(){function e(){return this.options.xhtml?""+se(c.message+"",!0)+"";throw c}}return pe.options=pe.setOptions=function(e){return ue(pe.defaults,e),fe(pe.defaults),pe},pe.getDefaults=le,pe.defaults=de,pe.use=function(e){var t=ue({},e);if(e.renderer&&function(){var n=pe.defaults.renderer||new te,r=function(){function t(t){var r=n[t];n[t]=function(){for(var o=arguments.length,i=new Array(o),a=0;a